mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into feat/subscription-tier-billing
This commit is contained in:
@@ -48,14 +48,15 @@ git diff "$BASE_BRANCH"...HEAD -- src/ | head -500
|
||||
For each changed file, determine:
|
||||
|
||||
1. **Is it a page?** (`page.tsx`) — these are the primary test targets
|
||||
2. **Is it a hook?** (`use*.ts`) — test via the page that uses it
|
||||
2. **Is it a hook?** (`use*.ts`) — test via the page/component that uses it; avoid direct `renderHook()` tests unless it is a shared reusable hook with standalone business logic
|
||||
3. **Is it a component?** (`.tsx` in `components/`) — test via the parent page unless it's complex enough to warrant isolation
|
||||
4. **Is it a helper?** (`helpers.ts`, `utils.ts`) — unit test directly if pure logic
|
||||
|
||||
**Priority order:**
|
||||
|
||||
1. Pages with new/changed data fetching or user interactions
|
||||
2. Components with complex internal logic (modals, forms, wizards)
|
||||
3. Hooks with non-trivial business logic
|
||||
3. Shared hooks with standalone business logic when UI-level coverage is impractical
|
||||
4. Pure helper functions
|
||||
|
||||
Skip: styling-only changes, type-only changes, config changes.
|
||||
@@ -163,6 +164,7 @@ describe("LibraryPage", () => {
|
||||
- Use `waitFor` when asserting side effects or state changes after interactions
|
||||
- Import `fireEvent` or `userEvent` from the test-utils for interactions
|
||||
- Do NOT mock internal hooks or functions — mock at the API boundary via MSW
|
||||
- Prefer Orval-generated MSW handlers and response builders over hand-built API response objects
|
||||
- Do NOT use `act()` manually — `render` and `fireEvent` handle it
|
||||
- Keep tests focused: one behavior per test
|
||||
- Use descriptive test names that read like sentences
|
||||
@@ -190,9 +192,7 @@ import { http, HttpResponse } from "msw";
|
||||
server.use(
|
||||
http.get("http://localhost:3000/api/proxy/api/v2/library/agents", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [
|
||||
{ id: "1", name: "Test Agent", description: "A test agent" },
|
||||
],
|
||||
agents: [{ id: "1", name: "Test Agent", description: "A test agent" }],
|
||||
pagination: { total_items: 1, total_pages: 1, page: 1, page_size: 10 },
|
||||
});
|
||||
}),
|
||||
@@ -211,6 +211,7 @@ pnpm test:unit --reporter=verbose
|
||||
```
|
||||
|
||||
If tests fail:
|
||||
|
||||
1. Read the error output carefully
|
||||
2. Fix the test (not the source code, unless there is a genuine bug)
|
||||
3. Re-run until all pass
|
||||
|
||||
13
.github/workflows/platform-fullstack-ci.yml
vendored
13
.github/workflows/platform-fullstack-ci.yml
vendored
@@ -160,6 +160,7 @@ jobs:
|
||||
run: |
|
||||
cp ../backend/.env.default ../backend/.env
|
||||
echo "OPENAI_INTERNAL_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> ../backend/.env
|
||||
echo "SCHEDULER_STARTUP_EMBEDDING_BACKFILL=false" >> ../backend/.env
|
||||
env:
|
||||
# Used by E2E test data script to generate embeddings for approved store agents
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -288,6 +289,14 @@ jobs:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up tests - Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- name: Copy source maps from Docker for E2E coverage
|
||||
run: |
|
||||
FRONTEND_CONTAINER=$(docker compose -f ../docker-compose.resolved.yml ps -q frontend)
|
||||
@@ -299,8 +308,8 @@ jobs:
|
||||
- name: Set up tests - Install browser 'chromium'
|
||||
run: pnpm playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:no-build
|
||||
- name: Run Playwright E2E suite
|
||||
run: pnpm test:e2e:no-build
|
||||
continue-on-error: false
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
|
||||
166
autogpt_platform/backend/agents/calculator-agent.json
Normal file
166
autogpt_platform/backend/agents/calculator-agent.json
Normal file
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"id": "858e2226-e047-4d19-a832-3be4a134d155",
|
||||
"version": 2,
|
||||
"is_active": true,
|
||||
"name": "Calculator agent",
|
||||
"description": "",
|
||||
"instructions": null,
|
||||
"recommended_schedule_cron": null,
|
||||
"forked_from_id": null,
|
||||
"forked_from_version": null,
|
||||
"user_id": "",
|
||||
"created_at": "2026-04-13T03:45:11.241Z",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "6762da5d-6915-4836-a431-6dcd7d36a54a",
|
||||
"block_id": "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
|
||||
"input_default": {
|
||||
"name": "Input",
|
||||
"secret": false,
|
||||
"advanced": false
|
||||
},
|
||||
"metadata": {
|
||||
"position": {
|
||||
"x": -188.2244873046875,
|
||||
"y": 95
|
||||
}
|
||||
},
|
||||
"input_links": [],
|
||||
"output_links": [
|
||||
{
|
||||
"id": "432c7caa-49b9-4b70-bd21-2fa33a569601",
|
||||
"source_id": "6762da5d-6915-4836-a431-6dcd7d36a54a",
|
||||
"sink_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"source_name": "result",
|
||||
"sink_name": "a",
|
||||
"is_static": true
|
||||
}
|
||||
],
|
||||
"graph_id": "858e2226-e047-4d19-a832-3be4a134d155",
|
||||
"graph_version": 2,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "65429c9e-a0c6-4032-a421-6899c394fa74",
|
||||
"block_id": "363ae599-353e-4804-937e-b2ee3cef3da4",
|
||||
"input_default": {
|
||||
"name": "Output",
|
||||
"secret": false,
|
||||
"advanced": false,
|
||||
"escape_html": false
|
||||
},
|
||||
"metadata": {
|
||||
"position": {
|
||||
"x": 825.198974609375,
|
||||
"y": 123.75
|
||||
}
|
||||
},
|
||||
"input_links": [
|
||||
{
|
||||
"id": "8cdb2f33-5b10-4cc2-8839-f8ccb70083a3",
|
||||
"source_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"sink_id": "65429c9e-a0c6-4032-a421-6899c394fa74",
|
||||
"source_name": "result",
|
||||
"sink_name": "value",
|
||||
"is_static": false
|
||||
}
|
||||
],
|
||||
"output_links": [],
|
||||
"graph_id": "858e2226-e047-4d19-a832-3be4a134d155",
|
||||
"graph_version": 2,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"block_id": "b1ab9b19-67a6-406d-abf5-2dba76d00c79",
|
||||
"input_default": {
|
||||
"b": 34,
|
||||
"operation": "Add",
|
||||
"round_result": false
|
||||
},
|
||||
"metadata": {
|
||||
"position": {
|
||||
"x": 323.0255126953125,
|
||||
"y": 121.25
|
||||
}
|
||||
},
|
||||
"input_links": [
|
||||
{
|
||||
"id": "432c7caa-49b9-4b70-bd21-2fa33a569601",
|
||||
"source_id": "6762da5d-6915-4836-a431-6dcd7d36a54a",
|
||||
"sink_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"source_name": "result",
|
||||
"sink_name": "a",
|
||||
"is_static": true
|
||||
}
|
||||
],
|
||||
"output_links": [
|
||||
{
|
||||
"id": "8cdb2f33-5b10-4cc2-8839-f8ccb70083a3",
|
||||
"source_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"sink_id": "65429c9e-a0c6-4032-a421-6899c394fa74",
|
||||
"source_name": "result",
|
||||
"sink_name": "value",
|
||||
"is_static": false
|
||||
}
|
||||
],
|
||||
"graph_id": "858e2226-e047-4d19-a832-3be4a134d155",
|
||||
"graph_version": 2,
|
||||
"webhook_id": null
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "8cdb2f33-5b10-4cc2-8839-f8ccb70083a3",
|
||||
"source_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"sink_id": "65429c9e-a0c6-4032-a421-6899c394fa74",
|
||||
"source_name": "result",
|
||||
"sink_name": "value",
|
||||
"is_static": false
|
||||
},
|
||||
{
|
||||
"id": "432c7caa-49b9-4b70-bd21-2fa33a569601",
|
||||
"source_id": "6762da5d-6915-4836-a431-6dcd7d36a54a",
|
||||
"sink_id": "bf4a15ff-b0c4-4032-a21b-5880224af690",
|
||||
"source_name": "result",
|
||||
"sink_name": "a",
|
||||
"is_static": true
|
||||
}
|
||||
],
|
||||
"sub_graphs": [],
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Input": {
|
||||
"advanced": false,
|
||||
"secret": false,
|
||||
"title": "Input"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Input"
|
||||
]
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Output": {
|
||||
"advanced": false,
|
||||
"secret": false,
|
||||
"title": "Output"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Output"
|
||||
]
|
||||
},
|
||||
"has_external_trigger": false,
|
||||
"has_human_in_the_loop": false,
|
||||
"has_sensitive_action": false,
|
||||
"trigger_setup_info": null,
|
||||
"credentials_input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ from prisma.models import User as PrismaUser
|
||||
from prisma.types import PlatformCostLogCreateInput, PlatformCostLogWhereInput
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.db import query_raw_with_schema
|
||||
from backend.util.cache import cached
|
||||
from backend.util.json import SafeJson
|
||||
|
||||
@@ -142,6 +143,7 @@ class UserCostSummary(BaseModel):
|
||||
total_cache_read_tokens: int = 0
|
||||
total_cache_creation_tokens: int = 0
|
||||
request_count: int
|
||||
cost_bearing_request_count: int = 0
|
||||
|
||||
|
||||
class CostLogRow(BaseModel):
|
||||
@@ -163,12 +165,27 @@ class CostLogRow(BaseModel):
|
||||
cache_creation_tokens: int | None = None
|
||||
|
||||
|
||||
class CostBucket(BaseModel):
|
||||
bucket: str
|
||||
count: int
|
||||
|
||||
|
||||
class PlatformCostDashboard(BaseModel):
|
||||
by_provider: list[ProviderCostSummary]
|
||||
by_user: list[UserCostSummary]
|
||||
total_cost_microdollars: int
|
||||
total_requests: int
|
||||
total_users: int
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
avg_input_tokens_per_request: float = 0.0
|
||||
avg_output_tokens_per_request: float = 0.0
|
||||
avg_cost_microdollars_per_request: float = 0.0
|
||||
cost_p50_microdollars: float = 0.0
|
||||
cost_p75_microdollars: float = 0.0
|
||||
cost_p95_microdollars: float = 0.0
|
||||
cost_p99_microdollars: float = 0.0
|
||||
cost_buckets: list[CostBucket] = []
|
||||
|
||||
|
||||
def _si(row: dict, field: str) -> int:
|
||||
@@ -228,6 +245,66 @@ def _build_prisma_where(
|
||||
return where
|
||||
|
||||
|
||||
def _build_raw_where(
|
||||
start: datetime | None,
|
||||
end: datetime | None,
|
||||
provider: str | None,
|
||||
user_id: str | None,
|
||||
model: str | None = None,
|
||||
block_name: str | None = None,
|
||||
tracking_type: str | None = None,
|
||||
) -> tuple[str, list]:
|
||||
"""Build a parameterised WHERE clause for raw SQL queries.
|
||||
|
||||
Mirrors the filter logic of ``_build_prisma_where`` so there is a single
|
||||
source of truth for which columns are filtered and how. The first clause
|
||||
always restricts to ``cost_usd`` tracking type unless *tracking_type* is
|
||||
explicitly provided by the caller.
|
||||
"""
|
||||
params: list = []
|
||||
clauses: list[str] = []
|
||||
idx = 1
|
||||
|
||||
# Always filter by tracking type — defaults to cost_usd for percentile /
|
||||
# bucket queries that only make sense on cost-denominated rows.
|
||||
tt = tracking_type if tracking_type is not None else "cost_usd"
|
||||
clauses.append(f'"trackingType" = ${idx}')
|
||||
params.append(tt)
|
||||
idx += 1
|
||||
|
||||
if start is not None:
|
||||
clauses.append(f'"createdAt" >= ${idx}::timestamptz')
|
||||
params.append(start)
|
||||
idx += 1
|
||||
|
||||
if end is not None:
|
||||
clauses.append(f'"createdAt" <= ${idx}::timestamptz')
|
||||
params.append(end)
|
||||
idx += 1
|
||||
|
||||
if provider is not None:
|
||||
clauses.append(f'"provider" = ${idx}')
|
||||
params.append(provider.lower())
|
||||
idx += 1
|
||||
|
||||
if user_id is not None:
|
||||
clauses.append(f'"userId" = ${idx}')
|
||||
params.append(user_id)
|
||||
idx += 1
|
||||
|
||||
if model is not None:
|
||||
clauses.append(f'"model" = ${idx}')
|
||||
params.append(model)
|
||||
idx += 1
|
||||
|
||||
if block_name is not None:
|
||||
clauses.append(f'LOWER("blockName") = LOWER(${idx})')
|
||||
params.append(block_name)
|
||||
idx += 1
|
||||
|
||||
return (" AND ".join(clauses), params)
|
||||
|
||||
|
||||
@cached(ttl_seconds=30)
|
||||
async def get_platform_cost_dashboard(
|
||||
start: datetime | None = None,
|
||||
@@ -256,6 +333,14 @@ async def get_platform_cost_dashboard(
|
||||
start, end, provider, user_id, model, block_name, tracking_type
|
||||
)
|
||||
|
||||
# For per-user tracking-type breakdown we intentionally omit the
|
||||
# tracking_type filter so cost_usd and tokens rows are always present.
|
||||
# This ensures cost_bearing_request_count is correct even when the caller
|
||||
# is filtering the main view by a different tracking_type.
|
||||
where_no_tracking_type = _build_prisma_where(
|
||||
start, end, provider, user_id, model, block_name, tracking_type=None
|
||||
)
|
||||
|
||||
sum_fields = {
|
||||
"costMicrodollars": True,
|
||||
"inputTokens": True,
|
||||
@@ -266,13 +351,18 @@ async def get_platform_cost_dashboard(
|
||||
"trackingAmount": True,
|
||||
}
|
||||
|
||||
# Run all four aggregation queries in parallel.
|
||||
(
|
||||
by_provider_groups,
|
||||
by_user_groups,
|
||||
total_user_groups,
|
||||
total_agg_groups,
|
||||
) = await asyncio.gather(
|
||||
# Build parameterised WHERE clause for the raw SQL percentile/bucket
|
||||
# queries. Uses _build_raw_where so filter logic is shared with
|
||||
# _build_prisma_where and only maintained in one place.
|
||||
# Always force tracking_type=None here so _build_raw_where defaults to
|
||||
# "cost_usd" — percentile and histogram queries only make sense on
|
||||
# cost-denominated rows, regardless of what the caller is filtering.
|
||||
raw_where, raw_params = _build_raw_where(
|
||||
start, end, provider, user_id, model, block_name, tracking_type=None
|
||||
)
|
||||
|
||||
# Queries that always run regardless of tracking_type filter.
|
||||
common_queries = [
|
||||
# (provider, trackingType, model) aggregation — no ORDER BY in ORM;
|
||||
# sort by total cost descending in Python after fetch.
|
||||
PrismaLog.prisma().group_by(
|
||||
@@ -288,20 +378,125 @@ async def get_platform_cost_dashboard(
|
||||
sum=sum_fields,
|
||||
count=True,
|
||||
),
|
||||
# Per-user cost-bearing request count: group by (userId, trackingType)
|
||||
# so we can compute the correct denominator for per-user avg cost.
|
||||
# Uses where_no_tracking_type so cost_usd rows are always included
|
||||
# even when the caller filters the main view by a different tracking_type.
|
||||
PrismaLog.prisma().group_by(
|
||||
by=["userId", "trackingType"],
|
||||
where=where_no_tracking_type,
|
||||
count=True,
|
||||
),
|
||||
# Distinct user count: group by userId, count groups.
|
||||
PrismaLog.prisma().group_by(
|
||||
by=["userId"],
|
||||
where=where,
|
||||
count=True,
|
||||
),
|
||||
# Total aggregate: group by provider (no limit) to sum across all
|
||||
# matching rows. Summed in Python to get grand totals.
|
||||
# Total aggregate (filtered): group by (provider, trackingType) so we can
|
||||
# compute cost-bearing and token-bearing denominators for avg stats.
|
||||
PrismaLog.prisma().group_by(
|
||||
by=["provider"],
|
||||
by=["provider", "trackingType"],
|
||||
where=where,
|
||||
sum={"costMicrodollars": True},
|
||||
sum={
|
||||
"costMicrodollars": True,
|
||||
"inputTokens": True,
|
||||
"outputTokens": True,
|
||||
},
|
||||
count=True,
|
||||
),
|
||||
# Percentile distribution of cost per request (respects all filters).
|
||||
query_raw_with_schema(
|
||||
"SELECT"
|
||||
" percentile_cont(0.5) WITHIN GROUP"
|
||||
' (ORDER BY "costMicrodollars") as p50,'
|
||||
" percentile_cont(0.75) WITHIN GROUP"
|
||||
' (ORDER BY "costMicrodollars") as p75,'
|
||||
" percentile_cont(0.95) WITHIN GROUP"
|
||||
' (ORDER BY "costMicrodollars") as p95,'
|
||||
" percentile_cont(0.99) WITHIN GROUP"
|
||||
' (ORDER BY "costMicrodollars") as p99'
|
||||
' FROM {schema_prefix}"PlatformCostLog"'
|
||||
f" WHERE {raw_where}",
|
||||
*raw_params,
|
||||
),
|
||||
# Histogram buckets for cost distribution (respects all filters).
|
||||
# NULL costMicrodollars is excluded explicitly to prevent such rows
|
||||
# from falling through all WHEN clauses into the ELSE '$10+' bucket.
|
||||
query_raw_with_schema(
|
||||
"SELECT"
|
||||
" CASE"
|
||||
' WHEN "costMicrodollars" < 500000'
|
||||
" THEN '$0-0.50'"
|
||||
' WHEN "costMicrodollars" < 1000000'
|
||||
" THEN '$0.50-1'"
|
||||
' WHEN "costMicrodollars" < 2000000'
|
||||
" THEN '$1-2'"
|
||||
' WHEN "costMicrodollars" < 5000000'
|
||||
" THEN '$2-5'"
|
||||
' WHEN "costMicrodollars" < 10000000'
|
||||
" THEN '$5-10'"
|
||||
" ELSE '$10+'"
|
||||
" END as bucket,"
|
||||
" COUNT(*) as count"
|
||||
' FROM {schema_prefix}"PlatformCostLog"'
|
||||
f' WHERE {raw_where} AND "costMicrodollars" IS NOT NULL'
|
||||
" GROUP BY bucket"
|
||||
' ORDER BY MIN("costMicrodollars")',
|
||||
*raw_params,
|
||||
),
|
||||
]
|
||||
|
||||
# Only run the unfiltered aggregate query when tracking_type is set;
|
||||
# when tracking_type is None, the filtered query already contains all
|
||||
# tracking types and reusing it avoids a redundant full aggregation.
|
||||
if tracking_type is not None:
|
||||
common_queries.append(
|
||||
# Total aggregate (no tracking_type filter): used to compute
|
||||
# cost_bearing_requests and token_bearing_requests denominators so
|
||||
# global avg stats remain meaningful when the caller filters the
|
||||
# main view by a specific tracking_type (e.g. 'tokens').
|
||||
PrismaLog.prisma().group_by(
|
||||
by=["provider", "trackingType"],
|
||||
where=where_no_tracking_type,
|
||||
sum={
|
||||
"costMicrodollars": True,
|
||||
"inputTokens": True,
|
||||
"outputTokens": True,
|
||||
},
|
||||
count=True,
|
||||
)
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*common_queries)
|
||||
|
||||
# Unpack results by name for clarity.
|
||||
by_provider_groups = results[0]
|
||||
by_user_groups = results[1]
|
||||
by_user_tracking_groups = results[2]
|
||||
total_user_groups = results[3]
|
||||
total_agg_groups = results[4]
|
||||
percentile_rows = results[5]
|
||||
bucket_rows = results[6]
|
||||
# When tracking_type is None, the filtered and unfiltered queries are
|
||||
# identical — reuse total_agg_groups to avoid the extra DB round-trip.
|
||||
total_agg_no_tracking_type_groups = (
|
||||
results[7] if tracking_type is not None else total_agg_groups
|
||||
)
|
||||
|
||||
# Compute token grand-totals from the unfiltered aggregate so they remain
|
||||
# consistent with the avg-token stats (which also use unfiltered data).
|
||||
# Using by_provider_groups here would give 0 tokens when tracking_type='cost_usd'
|
||||
# because cost_usd rows carry no token data, contradicting non-zero averages.
|
||||
total_input_tokens = sum(
|
||||
_si(r, "inputTokens")
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "tokens"
|
||||
)
|
||||
total_output_tokens = sum(
|
||||
_si(r, "outputTokens")
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "tokens"
|
||||
)
|
||||
|
||||
# Sort by_provider by total cost descending and cap at MAX_PROVIDER_ROWS.
|
||||
@@ -328,6 +523,61 @@ async def get_platform_cost_dashboard(
|
||||
total_cost = sum(_si(r, "costMicrodollars") for r in total_agg_groups)
|
||||
total_requests = sum(_ca(r) for r in total_agg_groups)
|
||||
|
||||
# Extract percentile values from the raw query result.
|
||||
pctl = percentile_rows[0] if percentile_rows else {}
|
||||
cost_p50 = float(pctl.get("p50") or 0)
|
||||
cost_p75 = float(pctl.get("p75") or 0)
|
||||
cost_p95 = float(pctl.get("p95") or 0)
|
||||
cost_p99 = float(pctl.get("p99") or 0)
|
||||
|
||||
# Build cost bucket list.
|
||||
cost_buckets: list[CostBucket] = [
|
||||
CostBucket(bucket=r["bucket"], count=int(r["count"])) for r in bucket_rows
|
||||
]
|
||||
|
||||
# Avg-stat numerators and denominators are derived from the unfiltered
|
||||
# aggregate so they remain meaningful when the caller filters by a specific
|
||||
# tracking_type. Example: filtering by 'tokens' excludes cost_usd rows from
|
||||
# total_agg_groups, so avg_cost would always be 0 if we used that; using
|
||||
# total_agg_no_tracking_type_groups gives the correct cost_usd total/count.
|
||||
avg_cost_total = sum(
|
||||
_si(r, "costMicrodollars")
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "cost_usd"
|
||||
)
|
||||
cost_bearing_requests = sum(
|
||||
_ca(r)
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "cost_usd"
|
||||
)
|
||||
avg_input_total = sum(
|
||||
_si(r, "inputTokens")
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "tokens"
|
||||
)
|
||||
avg_output_total = sum(
|
||||
_si(r, "outputTokens")
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "tokens"
|
||||
)
|
||||
# Token-bearing request count: only rows where trackingType == "tokens".
|
||||
# Token averages must use this denominator; cost_usd rows do not carry tokens.
|
||||
token_bearing_requests = sum(
|
||||
_ca(r)
|
||||
for r in total_agg_no_tracking_type_groups
|
||||
if r.get("trackingType") == "tokens"
|
||||
)
|
||||
|
||||
# Per-user cost-bearing request count: used for per-user avg cost so the
|
||||
# denominator matches the numerator (cost_usd rows only, per user).
|
||||
user_cost_bearing_counts: dict[str, int] = {}
|
||||
for r in by_user_tracking_groups:
|
||||
if r.get("trackingType") == "cost_usd" and r.get("userId"):
|
||||
uid = r["userId"]
|
||||
user_cost_bearing_counts[uid] = user_cost_bearing_counts.get(uid, 0) + _ca(
|
||||
r
|
||||
)
|
||||
|
||||
return PlatformCostDashboard(
|
||||
by_provider=[
|
||||
ProviderCostSummary(
|
||||
@@ -355,12 +605,35 @@ async def get_platform_cost_dashboard(
|
||||
total_cache_read_tokens=_si(r, "cacheReadTokens"),
|
||||
total_cache_creation_tokens=_si(r, "cacheCreationTokens"),
|
||||
request_count=_ca(r),
|
||||
cost_bearing_request_count=user_cost_bearing_counts.get(
|
||||
r.get("userId") or "", 0
|
||||
),
|
||||
)
|
||||
for r in by_user_groups
|
||||
],
|
||||
total_cost_microdollars=total_cost,
|
||||
total_requests=total_requests,
|
||||
total_users=total_users,
|
||||
total_input_tokens=total_input_tokens,
|
||||
total_output_tokens=total_output_tokens,
|
||||
avg_input_tokens_per_request=(
|
||||
avg_input_total / token_bearing_requests
|
||||
if token_bearing_requests > 0
|
||||
else 0.0
|
||||
),
|
||||
avg_output_tokens_per_request=(
|
||||
avg_output_total / token_bearing_requests
|
||||
if token_bearing_requests > 0
|
||||
else 0.0
|
||||
),
|
||||
avg_cost_microdollars_per_request=(
|
||||
avg_cost_total / cost_bearing_requests if cost_bearing_requests > 0 else 0.0
|
||||
),
|
||||
cost_p50_microdollars=cost_p50,
|
||||
cost_p75_microdollars=cost_p75,
|
||||
cost_p95_microdollars=cost_p95,
|
||||
cost_p99_microdollars=cost_p99,
|
||||
cost_buckets=cost_buckets,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from backend.util.json import SafeJson
|
||||
|
||||
from .platform_cost import (
|
||||
PlatformCostEntry,
|
||||
_build_prisma_where,
|
||||
_build_raw_where,
|
||||
_build_where,
|
||||
_mask_email,
|
||||
get_platform_cost_dashboard,
|
||||
@@ -156,6 +158,84 @@ class TestBuildWhere:
|
||||
assert 'p."trackingType" = $3' in sql
|
||||
|
||||
|
||||
class TestBuildPrismaWhere:
|
||||
def test_both_start_and_end(self):
|
||||
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
where = _build_prisma_where(start, end, None, None)
|
||||
assert where["createdAt"] == {"gte": start, "lte": end}
|
||||
|
||||
def test_end_only(self):
|
||||
end = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
where = _build_prisma_where(None, end, None, None)
|
||||
assert where["createdAt"] == {"lte": end}
|
||||
|
||||
def test_start_only(self):
|
||||
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
where = _build_prisma_where(start, None, None, None)
|
||||
assert where["createdAt"] == {"gte": start}
|
||||
|
||||
def test_no_filters(self):
|
||||
where = _build_prisma_where(None, None, None, None)
|
||||
assert "createdAt" not in where
|
||||
|
||||
def test_provider_lowercased(self):
|
||||
where = _build_prisma_where(None, None, "OpenAI", None)
|
||||
assert where["provider"] == "openai"
|
||||
|
||||
def test_model_filter(self):
|
||||
where = _build_prisma_where(None, None, None, None, model="gpt-4")
|
||||
assert where["model"] == "gpt-4"
|
||||
|
||||
def test_block_name_case_insensitive(self):
|
||||
where = _build_prisma_where(None, None, None, None, block_name="LLMBlock")
|
||||
assert where["blockName"] == {"equals": "LLMBlock", "mode": "insensitive"}
|
||||
|
||||
def test_tracking_type(self):
|
||||
where = _build_prisma_where(None, None, None, None, tracking_type="tokens")
|
||||
assert where["trackingType"] == "tokens"
|
||||
|
||||
|
||||
class TestBuildRawWhere:
|
||||
def test_end_filter(self):
|
||||
end = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
sql, params = _build_raw_where(None, end, None, None)
|
||||
assert '"createdAt" <= $2::timestamptz' in sql
|
||||
assert end in params
|
||||
|
||||
def test_model_filter(self):
|
||||
sql, params = _build_raw_where(None, None, None, None, model="gpt-4")
|
||||
assert '"model" = $' in sql
|
||||
assert "gpt-4" in params
|
||||
|
||||
def test_block_name_filter(self):
|
||||
sql, params = _build_raw_where(None, None, None, None, block_name="LLMBlock")
|
||||
assert 'LOWER("blockName") = LOWER($' in sql
|
||||
assert "LLMBlock" in params
|
||||
|
||||
def test_all_filters_combined(self):
|
||||
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
sql, params = _build_raw_where(
|
||||
start, end, "anthropic", "u1", model="claude-3", block_name="LLM"
|
||||
)
|
||||
# trackingType (default), start, end, provider, user_id, model, block_name
|
||||
assert len(params) == 7
|
||||
assert "anthropic" in params
|
||||
assert "u1" in params
|
||||
assert "claude-3" in params
|
||||
assert "LLM" in params
|
||||
|
||||
def test_default_tracking_type_is_cost_usd(self):
|
||||
sql, params = _build_raw_where(None, None, None, None)
|
||||
assert '"trackingType" = $1' in sql
|
||||
assert params[0] == "cost_usd"
|
||||
|
||||
def test_explicit_tracking_type_overrides_default(self):
|
||||
sql, params = _build_raw_where(None, None, None, None, tracking_type="tokens")
|
||||
assert params[0] == "tokens"
|
||||
|
||||
|
||||
def _make_entry(**overrides: object) -> PlatformCostEntry:
|
||||
return PlatformCostEntry.model_validate(
|
||||
{
|
||||
@@ -286,8 +366,9 @@ class TestGetPlatformCostDashboard:
|
||||
side_effect=[
|
||||
[provider_row], # by_provider
|
||||
[user_row], # by_user
|
||||
[], # by_user_tracking_groups (no cost_usd rows for this user)
|
||||
[{"userId": "u1"}], # distinct users
|
||||
[provider_row], # total agg
|
||||
[provider_row], # total agg (tracking_type=None → same as unfiltered)
|
||||
]
|
||||
)
|
||||
mock_actions.find_many = AsyncMock(return_value=[mock_user])
|
||||
@@ -301,6 +382,14 @@ class TestGetPlatformCostDashboard:
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[
|
||||
[{"p50": 1000, "p75": 2000, "p95": 4000, "p99": 5000}],
|
||||
[{"bucket": "$0-0.50", "count": 3}],
|
||||
],
|
||||
),
|
||||
):
|
||||
dashboard = await get_platform_cost_dashboard()
|
||||
|
||||
@@ -313,6 +402,131 @@ class TestGetPlatformCostDashboard:
|
||||
assert dashboard.by_provider[0].total_duration_seconds == 10.5
|
||||
assert len(dashboard.by_user) == 1
|
||||
assert dashboard.by_user[0].email == "a***@b.com"
|
||||
assert dashboard.cost_p50_microdollars == 1000
|
||||
assert dashboard.cost_p75_microdollars == 2000
|
||||
assert dashboard.cost_p95_microdollars == 4000
|
||||
assert dashboard.cost_p99_microdollars == 5000
|
||||
assert len(dashboard.cost_buckets) == 1
|
||||
# total_input/output_tokens come from total_agg_no_tracking_type_groups
|
||||
# (provider_row has 1000/500)
|
||||
assert dashboard.total_input_tokens == 1000
|
||||
assert dashboard.total_output_tokens == 500
|
||||
# Token averages must use token_bearing_requests (3) not cost_bearing (0)
|
||||
assert dashboard.avg_input_tokens_per_request == pytest.approx(1000 / 3)
|
||||
assert dashboard.avg_output_tokens_per_request == pytest.approx(500 / 3)
|
||||
# No cost_usd rows in total_agg → avg_cost should be 0
|
||||
assert dashboard.avg_cost_microdollars_per_request == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_bearing_request_count_nonzero_when_filtering_by_tokens(self):
|
||||
"""When filtering by tracking_type='tokens', cost_bearing_request_count
|
||||
must still reflect cost_usd rows because by_user_tracking_groups is
|
||||
queried without the tracking_type constraint."""
|
||||
# total_agg only has a tokens row (because of the tracking_type filter)
|
||||
total_row = _make_group_by_row(
|
||||
provider="openai", tracking_type="tokens", cost=0, count=5
|
||||
)
|
||||
# by_user_tracking_groups returns BOTH rows (no tracking_type filter)
|
||||
user_tracking_cost_usd_row = {
|
||||
"_count": {"_all": 7},
|
||||
"userId": "u1",
|
||||
"trackingType": "cost_usd",
|
||||
}
|
||||
user_tracking_tokens_row = {
|
||||
"_count": {"_all": 5},
|
||||
"userId": "u1",
|
||||
"trackingType": "tokens",
|
||||
}
|
||||
|
||||
mock_actions = MagicMock()
|
||||
mock_actions.group_by = AsyncMock(
|
||||
side_effect=[
|
||||
[total_row], # by_provider
|
||||
[{"_sum": {}, "_count": {"_all": 5}, "userId": "u1"}], # by_user
|
||||
[
|
||||
user_tracking_cost_usd_row,
|
||||
user_tracking_tokens_row,
|
||||
], # by_user_tracking
|
||||
[{"userId": "u1"}], # distinct users
|
||||
[total_row], # total agg (filtered)
|
||||
[total_row], # total agg (no tracking_type filter)
|
||||
]
|
||||
)
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaLog.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[[], []],
|
||||
),
|
||||
):
|
||||
dashboard = await get_platform_cost_dashboard(tracking_type="tokens")
|
||||
|
||||
# by_user has 1 user with 5 total requests (tokens rows only due to filter)
|
||||
# but per-user cost_bearing count should be 7 (from cost_usd rows in
|
||||
# by_user_tracking_groups which uses where_no_tracking_type)
|
||||
assert len(dashboard.by_user) == 1
|
||||
assert dashboard.by_user[0].cost_bearing_request_count == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_avg_cost_nonzero_when_filtering_by_tokens(self):
|
||||
"""When filtering by tracking_type='tokens', avg_cost_microdollars_per_request
|
||||
must still reflect cost_usd rows from total_agg_no_tracking_type_groups,
|
||||
not the filtered total_agg_groups which only has tokens rows."""
|
||||
# filtered total_agg only has tokens rows (zero cost)
|
||||
tokens_row = _make_group_by_row(
|
||||
provider="openai", tracking_type="tokens", cost=0, count=5
|
||||
)
|
||||
# unfiltered total_agg has both rows (cost_usd carries the actual cost)
|
||||
cost_usd_row = _make_group_by_row(
|
||||
provider="openai", tracking_type="cost_usd", cost=10_000, count=4
|
||||
)
|
||||
|
||||
mock_actions = MagicMock()
|
||||
mock_actions.group_by = AsyncMock(
|
||||
side_effect=[
|
||||
[tokens_row], # by_provider
|
||||
[{"_sum": {}, "_count": {"_all": 5}, "userId": "u1"}], # by_user
|
||||
[], # by_user_tracking_groups
|
||||
[{"userId": "u1"}], # distinct users
|
||||
[tokens_row], # total agg (filtered — tokens only)
|
||||
[tokens_row, cost_usd_row], # total agg (no tracking_type filter)
|
||||
]
|
||||
)
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaLog.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[[], []],
|
||||
),
|
||||
):
|
||||
dashboard = await get_platform_cost_dashboard(tracking_type="tokens")
|
||||
|
||||
# avg_cost_microdollars_per_request must be non-zero: cost_usd row
|
||||
# (10_000 microdollars, 4 requests) is present in the unfiltered agg.
|
||||
assert dashboard.avg_cost_microdollars_per_request == pytest.approx(10_000 / 4)
|
||||
# avg token stats use token_bearing_requests from unfiltered agg (5)
|
||||
assert dashboard.avg_input_tokens_per_request == pytest.approx(1000 / 5)
|
||||
assert dashboard.avg_output_tokens_per_request == pytest.approx(500 / 5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_tokens_aggregated_not_hardcoded(self):
|
||||
@@ -335,8 +549,9 @@ class TestGetPlatformCostDashboard:
|
||||
side_effect=[
|
||||
[provider_row], # by_provider
|
||||
[user_row], # by_user
|
||||
[], # by_user_tracking_groups
|
||||
[{"userId": "u2"}], # distinct users
|
||||
[provider_row], # total agg
|
||||
[provider_row], # total agg (tracking_type=None → same as unfiltered)
|
||||
]
|
||||
)
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
@@ -350,6 +565,14 @@ class TestGetPlatformCostDashboard:
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[
|
||||
[{"p50": 0, "p75": 0, "p95": 0, "p99": 0}],
|
||||
[],
|
||||
],
|
||||
),
|
||||
):
|
||||
dashboard = await get_platform_cost_dashboard()
|
||||
|
||||
@@ -361,7 +584,7 @@ class TestGetPlatformCostDashboard:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dashboard(self):
|
||||
mock_actions = MagicMock()
|
||||
mock_actions.group_by = AsyncMock(side_effect=[[], [], [], []])
|
||||
mock_actions.group_by = AsyncMock(side_effect=[[], [], [], [], []])
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
@@ -373,6 +596,11 @@ class TestGetPlatformCostDashboard:
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[[], []],
|
||||
),
|
||||
):
|
||||
dashboard = await get_platform_cost_dashboard()
|
||||
|
||||
@@ -381,13 +609,56 @@ class TestGetPlatformCostDashboard:
|
||||
assert dashboard.total_users == 0
|
||||
assert dashboard.by_provider == []
|
||||
assert dashboard.by_user == []
|
||||
assert dashboard.cost_p50_microdollars == 0
|
||||
assert dashboard.cost_buckets == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_filters_to_queries(self):
|
||||
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
mock_actions = MagicMock()
|
||||
mock_actions.group_by = AsyncMock(side_effect=[[], [], [], []])
|
||||
mock_actions.group_by = AsyncMock(side_effect=[[], [], [], [], []])
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
|
||||
raw_mock = AsyncMock(side_effect=[[], []])
|
||||
with (
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaLog.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
raw_mock,
|
||||
),
|
||||
):
|
||||
await get_platform_cost_dashboard(
|
||||
start=start, provider="openai", user_id="u1"
|
||||
)
|
||||
|
||||
# group_by called 5 times (by_provider, by_user, by_user_tracking, distinct users,
|
||||
# total agg filtered); the 6th call (total agg no-tracking-type) only runs
|
||||
# when tracking_type is set.
|
||||
assert mock_actions.group_by.await_count == 5
|
||||
# The where dict passed to the first call should include createdAt
|
||||
first_call_kwargs = mock_actions.group_by.call_args_list[0][1]
|
||||
assert "createdAt" in first_call_kwargs.get("where", {})
|
||||
# Raw SQL queries should receive provider and user_id as parameters
|
||||
assert raw_mock.await_count == 2
|
||||
raw_call_args = raw_mock.call_args_list[0][0] # positional args of 1st call
|
||||
raw_params = raw_call_args[1:] # first arg is the query template
|
||||
assert "openai" in raw_params
|
||||
assert "u1" in raw_params
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_tracking_groups_excludes_tracking_type_filter(self):
|
||||
"""by_user_tracking_groups must NOT apply the tracking_type filter so that
|
||||
cost_usd rows are always included even when the caller filters by 'tokens'."""
|
||||
mock_actions = MagicMock()
|
||||
mock_actions.group_by = AsyncMock(side_effect=[[], [], [], [], [], []])
|
||||
mock_actions.find_many = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
@@ -399,16 +670,23 @@ class TestGetPlatformCostDashboard:
|
||||
"backend.data.platform_cost.PrismaUser.prisma",
|
||||
return_value=mock_actions,
|
||||
),
|
||||
patch(
|
||||
"backend.data.platform_cost.query_raw_with_schema",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[[], []],
|
||||
),
|
||||
):
|
||||
await get_platform_cost_dashboard(
|
||||
start=start, provider="openai", user_id="u1"
|
||||
)
|
||||
await get_platform_cost_dashboard(tracking_type="tokens")
|
||||
|
||||
# group_by called 4 times (by_provider, by_user, distinct users, totals)
|
||||
assert mock_actions.group_by.await_count == 4
|
||||
# The where dict passed to the first call should include createdAt
|
||||
first_call_kwargs = mock_actions.group_by.call_args_list[0][1]
|
||||
assert "createdAt" in first_call_kwargs.get("where", {})
|
||||
# Call index 2 is by_user_tracking_groups (0=by_provider, 1=by_user,
|
||||
# 2=by_user_tracking, 3=distinct_users, 4=total_agg, 5=total_agg_no_tt).
|
||||
tracking_call_where = mock_actions.group_by.call_args_list[2][1]["where"]
|
||||
# The main filter applies trackingType; by_user_tracking must NOT.
|
||||
assert "trackingType" not in tracking_call_where
|
||||
# Other filters (e.g., date range, provider) are still passed through.
|
||||
# The first call (by_provider) should have trackingType in its where dict.
|
||||
provider_call_where = mock_actions.group_by.call_args_list[0][1]["where"]
|
||||
assert "trackingType" in provider_call_where
|
||||
|
||||
|
||||
def _make_prisma_log_row(
|
||||
|
||||
@@ -18,9 +18,13 @@ images: {
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import prisma.enums as prisma_enums
|
||||
import prisma.models as prisma_models
|
||||
from faker import Faker
|
||||
|
||||
# Import API functions from the backend
|
||||
@@ -30,10 +34,12 @@ from backend.api.features.store.db import (
|
||||
create_store_submission,
|
||||
review_store_submission,
|
||||
)
|
||||
from backend.api.features.store.model import StoreSubmission
|
||||
from backend.blocks.io import AgentInputBlock
|
||||
from backend.data.auth.api_key import create_api_key
|
||||
from backend.data.credit import get_user_credit_model
|
||||
from backend.data.db import prisma
|
||||
from backend.data.graph import Graph, Link, Node, create_graph
|
||||
from backend.data.graph import Graph, Link, Node, create_graph, make_graph_model
|
||||
from backend.data.user import get_or_create_user
|
||||
from backend.util.clients import get_supabase
|
||||
|
||||
@@ -60,6 +66,31 @@ MAX_REVIEWS_PER_VERSION = 5
|
||||
GUARANTEED_FEATURED_AGENTS = 8
|
||||
GUARANTEED_FEATURED_CREATORS = 5
|
||||
GUARANTEED_TOP_AGENTS = 10
|
||||
E2E_MARKETPLACE_CREATOR_EMAIL = "test123@example.com"
|
||||
E2E_MARKETPLACE_CREATOR_USERNAME = "e2e-marketplace"
|
||||
E2E_MARKETPLACE_AGENT_SLUG = "e2e-calculator-agent"
|
||||
E2E_MARKETPLACE_AGENT_NAME = "E2E Calculator Agent"
|
||||
E2E_MARKETPLACE_AGENT_INPUT_VALUE = 8
|
||||
E2E_MARKETPLACE_AGENT_OUTPUT_VALUE = 42
|
||||
_LOCAL_TEMPLATE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "agents" / "calculator-agent.json"
|
||||
)
|
||||
_DOCKER_TEMPLATE_PATH = Path(
|
||||
"/app/autogpt_platform/backend/agents/calculator-agent.json"
|
||||
)
|
||||
E2E_MARKETPLACE_AGENT_TEMPLATE_PATH = (
|
||||
_LOCAL_TEMPLATE_PATH if _LOCAL_TEMPLATE_PATH.exists() else _DOCKER_TEMPLATE_PATH
|
||||
)
|
||||
SEEDED_TEST_EMAILS = [
|
||||
"test123@example.com",
|
||||
"e2e.qa.auth@example.com",
|
||||
"e2e.qa.builder@example.com",
|
||||
"e2e.qa.library@example.com",
|
||||
"e2e.qa.marketplace@example.com",
|
||||
"e2e.qa.settings@example.com",
|
||||
"e2e.qa.parallel.a@example.com",
|
||||
"e2e.qa.parallel.b@example.com",
|
||||
]
|
||||
|
||||
|
||||
def get_image():
|
||||
@@ -100,6 +131,25 @@ def get_category():
|
||||
return random.choice(categories)
|
||||
|
||||
|
||||
def load_deterministic_marketplace_graph() -> Graph:
|
||||
graph = Graph.model_validate(
|
||||
json.loads(E2E_MARKETPLACE_AGENT_TEMPLATE_PATH.read_text())
|
||||
)
|
||||
graph.name = E2E_MARKETPLACE_AGENT_NAME
|
||||
graph.description = (
|
||||
"Deterministic marketplace calculator graph for Playwright PR E2E coverage."
|
||||
)
|
||||
|
||||
for node in graph.nodes:
|
||||
if (
|
||||
node.block_id == AgentInputBlock().id
|
||||
and node.input_default.get("value") is None
|
||||
):
|
||||
node.input_default["value"] = E2E_MARKETPLACE_AGENT_INPUT_VALUE
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
class TestDataCreator:
|
||||
"""Creates test data using API functions for E2E tests."""
|
||||
|
||||
@@ -123,9 +173,9 @@ class TestDataCreator:
|
||||
for i in range(NUM_USERS):
|
||||
try:
|
||||
# Generate test user data
|
||||
if i == 0:
|
||||
# First user should have test123@gmail.com email for testing
|
||||
email = "test123@gmail.com"
|
||||
if i < len(SEEDED_TEST_EMAILS):
|
||||
# Keep a deterministic pool for Playwright global setup and PR smoke flows
|
||||
email = SEEDED_TEST_EMAILS[i]
|
||||
else:
|
||||
email = faker.unique.email()
|
||||
password = "testpassword123" # Standard test password # pragma: allowlist secret # noqa
|
||||
@@ -547,6 +597,46 @@ class TestDataCreator:
|
||||
print(f"Error updating profile {profile.id}: {e}")
|
||||
continue
|
||||
|
||||
deterministic_creator = next(
|
||||
(
|
||||
user
|
||||
for user in self.users
|
||||
if user["email"] == E2E_MARKETPLACE_CREATOR_EMAIL
|
||||
),
|
||||
None,
|
||||
)
|
||||
if deterministic_creator:
|
||||
deterministic_profile = next(
|
||||
(
|
||||
profile
|
||||
for profile in existing_profiles
|
||||
if profile.userId == deterministic_creator["id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if deterministic_profile:
|
||||
try:
|
||||
updated_profile = await prisma.profile.update(
|
||||
where={"id": deterministic_profile.id},
|
||||
data={
|
||||
"name": "E2E Marketplace Creator",
|
||||
"username": E2E_MARKETPLACE_CREATOR_USERNAME,
|
||||
"description": "Deterministic marketplace creator for Playwright PR E2E coverage.",
|
||||
"links": ["https://example.com/e2e-marketplace"],
|
||||
"avatarUrl": get_image(),
|
||||
"isFeatured": True,
|
||||
},
|
||||
)
|
||||
profiles = [
|
||||
profile
|
||||
for profile in profiles
|
||||
if profile.get("id") != deterministic_profile.id
|
||||
]
|
||||
if updated_profile is not None:
|
||||
profiles.append(updated_profile.model_dump())
|
||||
except Exception as e:
|
||||
print(f"Error updating deterministic E2E creator profile: {e}")
|
||||
|
||||
self.profiles = profiles
|
||||
return profiles
|
||||
|
||||
@@ -562,58 +652,184 @@ class TestDataCreator:
|
||||
featured_count = 0
|
||||
submission_counter = 0
|
||||
|
||||
# Create a special test submission for test123@gmail.com (ALWAYS approved + featured)
|
||||
# Create a deterministic calculator marketplace agent for PR E2E coverage
|
||||
test_user = next(
|
||||
(user for user in self.users if user["email"] == "test123@gmail.com"), None
|
||||
(
|
||||
user
|
||||
for user in self.users
|
||||
if user["email"] == E2E_MARKETPLACE_CREATOR_EMAIL
|
||||
),
|
||||
None,
|
||||
)
|
||||
if test_user and self.agent_graphs:
|
||||
test_submission_data = {
|
||||
"user_id": test_user["id"],
|
||||
"graph_id": self.agent_graphs[0]["id"],
|
||||
"graph_version": 1,
|
||||
"slug": "test-agent-submission",
|
||||
"name": "Test Agent Submission",
|
||||
"sub_heading": "A test agent for frontend testing",
|
||||
"video_url": "https://www.youtube.com/watch?v=test123",
|
||||
"image_urls": [
|
||||
"https://picsum.photos/200/300",
|
||||
"https://picsum.photos/200/301",
|
||||
"https://picsum.photos/200/302",
|
||||
],
|
||||
"description": "This is a test agent submission specifically created for frontend testing purposes.",
|
||||
"categories": ["test", "demo", "frontend"],
|
||||
"changes_summary": "Initial test submission",
|
||||
}
|
||||
if test_user:
|
||||
deterministic_graph = None
|
||||
|
||||
try:
|
||||
test_submission = await create_store_submission(**test_submission_data)
|
||||
submissions.append(test_submission.model_dump())
|
||||
print("✅ Created special test store submission for test123@gmail.com")
|
||||
|
||||
# ALWAYS approve and feature the test submission
|
||||
if test_submission.listing_version_id:
|
||||
approved_submission = await review_store_submission(
|
||||
store_listing_version_id=test_submission.listing_version_id,
|
||||
is_approved=True,
|
||||
external_comments="Test submission approved",
|
||||
internal_comments="Auto-approved test submission",
|
||||
reviewer_id=test_user["id"],
|
||||
existing_graph = await prisma_models.AgentGraph.prisma().find_first(
|
||||
where={
|
||||
"userId": test_user["id"],
|
||||
"name": E2E_MARKETPLACE_AGENT_NAME,
|
||||
"isActive": True,
|
||||
},
|
||||
order={"version": "desc"},
|
||||
)
|
||||
if existing_graph:
|
||||
deterministic_graph = {
|
||||
"id": existing_graph.id,
|
||||
"version": existing_graph.version,
|
||||
"name": existing_graph.name,
|
||||
"userId": test_user["id"],
|
||||
}
|
||||
self.agent_graphs.append(deterministic_graph)
|
||||
print(
|
||||
"✅ Reused existing deterministic marketplace graph: "
|
||||
f"{existing_graph.id}"
|
||||
)
|
||||
approved_submissions.append(approved_submission.model_dump())
|
||||
print("✅ Approved test store submission")
|
||||
|
||||
await prisma.storelistingversion.update(
|
||||
where={"id": test_submission.listing_version_id},
|
||||
data={"isFeatured": True},
|
||||
else:
|
||||
deterministic_graph_model = make_graph_model(
|
||||
load_deterministic_marketplace_graph(),
|
||||
test_user["id"],
|
||||
)
|
||||
featured_count += 1
|
||||
print("🌟 Marked test agent as FEATURED")
|
||||
|
||||
deterministic_graph_model.reassign_ids(
|
||||
user_id=test_user["id"],
|
||||
reassign_graph_id=True,
|
||||
)
|
||||
created_deterministic_graph = await create_graph(
|
||||
deterministic_graph_model,
|
||||
test_user["id"],
|
||||
)
|
||||
deterministic_graph = created_deterministic_graph.model_dump()
|
||||
deterministic_graph["userId"] = test_user["id"]
|
||||
self.agent_graphs.append(deterministic_graph)
|
||||
print("✅ Created deterministic marketplace graph")
|
||||
except Exception as e:
|
||||
print(f"Error creating test store submission: {e}")
|
||||
import traceback
|
||||
print(f"Error creating deterministic marketplace graph: {e}")
|
||||
|
||||
traceback.print_exc()
|
||||
if deterministic_graph is None and self.agent_graphs:
|
||||
test_user_graphs = [
|
||||
graph
|
||||
for graph in self.agent_graphs
|
||||
if graph.get("userId") == test_user["id"]
|
||||
]
|
||||
deterministic_graph = next(
|
||||
(
|
||||
graph
|
||||
for graph in test_user_graphs
|
||||
if not graph.get("name", "").startswith("DummyInput ")
|
||||
),
|
||||
test_user_graphs[0] if test_user_graphs else None,
|
||||
)
|
||||
|
||||
if deterministic_graph:
|
||||
test_submission_data = {
|
||||
"user_id": test_user["id"],
|
||||
"graph_id": deterministic_graph["id"],
|
||||
"graph_version": deterministic_graph.get("version", 1),
|
||||
"slug": E2E_MARKETPLACE_AGENT_SLUG,
|
||||
"name": E2E_MARKETPLACE_AGENT_NAME,
|
||||
"sub_heading": "A deterministic calculator agent for PR E2E coverage",
|
||||
"video_url": "https://www.youtube.com/watch?v=test123",
|
||||
"image_urls": [
|
||||
"https://picsum.photos/seed/e2e-marketplace-1/200/300",
|
||||
"https://picsum.photos/seed/e2e-marketplace-2/200/301",
|
||||
"https://picsum.photos/seed/e2e-marketplace-3/200/302",
|
||||
],
|
||||
"description": (
|
||||
"A deterministic marketplace calculator agent that adds "
|
||||
f"{E2E_MARKETPLACE_AGENT_INPUT_VALUE} and 34 to produce "
|
||||
f"{E2E_MARKETPLACE_AGENT_OUTPUT_VALUE} for frontend E2E coverage."
|
||||
),
|
||||
"categories": ["test", "demo", "frontend"],
|
||||
"changes_summary": (
|
||||
"Initial deterministic calculator submission seeded from "
|
||||
"backend/agents/calculator-agent.json"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
existing_deterministic_submission = (
|
||||
await prisma_models.StoreListingVersion.prisma().find_first(
|
||||
where={
|
||||
"isDeleted": False,
|
||||
"StoreListing": {
|
||||
"is": {
|
||||
"owningUserId": test_user["id"],
|
||||
"slug": E2E_MARKETPLACE_AGENT_SLUG,
|
||||
"isDeleted": False,
|
||||
}
|
||||
},
|
||||
},
|
||||
include={"StoreListing": True},
|
||||
order={"version": "desc"},
|
||||
)
|
||||
)
|
||||
|
||||
if existing_deterministic_submission:
|
||||
test_submission = StoreSubmission.from_listing_version(
|
||||
existing_deterministic_submission
|
||||
)
|
||||
submissions.append(test_submission.model_dump())
|
||||
print(
|
||||
"✅ Reused deterministic marketplace submission: "
|
||||
f"{E2E_MARKETPLACE_AGENT_NAME}"
|
||||
)
|
||||
else:
|
||||
test_submission = await create_store_submission(
|
||||
**test_submission_data
|
||||
)
|
||||
submissions.append(test_submission.model_dump())
|
||||
print(
|
||||
"✅ Created deterministic marketplace submission: "
|
||||
f"{E2E_MARKETPLACE_AGENT_NAME}"
|
||||
)
|
||||
|
||||
current_status = (
|
||||
existing_deterministic_submission.submissionStatus
|
||||
if existing_deterministic_submission
|
||||
else test_submission.status
|
||||
)
|
||||
is_featured = bool(
|
||||
existing_deterministic_submission
|
||||
and existing_deterministic_submission.isFeatured
|
||||
)
|
||||
|
||||
if test_submission.listing_version_id:
|
||||
if current_status != prisma_enums.SubmissionStatus.APPROVED:
|
||||
approved_submission = await review_store_submission(
|
||||
store_listing_version_id=test_submission.listing_version_id,
|
||||
is_approved=True,
|
||||
external_comments="Deterministic calculator submission approved",
|
||||
internal_comments="Auto-approved PR E2E marketplace submission",
|
||||
reviewer_id=test_user["id"],
|
||||
)
|
||||
approved_submissions.append(
|
||||
approved_submission.model_dump()
|
||||
)
|
||||
print("✅ Approved deterministic marketplace submission")
|
||||
else:
|
||||
approved_submissions.append(test_submission.model_dump())
|
||||
print(
|
||||
"✅ Deterministic marketplace submission already approved"
|
||||
)
|
||||
|
||||
if is_featured:
|
||||
featured_count += 1
|
||||
print("🌟 Deterministic marketplace agent already FEATURED")
|
||||
else:
|
||||
await prisma.storelistingversion.update(
|
||||
where={"id": test_submission.listing_version_id},
|
||||
data={"isFeatured": True},
|
||||
)
|
||||
featured_count += 1
|
||||
print(
|
||||
"🌟 Marked deterministic marketplace agent as FEATURED"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating deterministic marketplace submission: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Create regular submissions for all users
|
||||
for user in self.users:
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
# 5. CLI arguments - docker compose run -e VAR=value
|
||||
|
||||
# Common backend environment - Docker service names
|
||||
x-backend-env: &backend-env # Docker internal service hostnames (override localhost defaults)
|
||||
x-backend-env:
|
||||
&backend-env # Docker internal service hostnames (override localhost defaults)
|
||||
PYRO_HOST: "0.0.0.0"
|
||||
AGENTSERVER_HOST: rest_server
|
||||
SCHEDULER_HOST: scheduler_server
|
||||
@@ -39,7 +40,12 @@ services:
|
||||
context: ../
|
||||
dockerfile: autogpt_platform/backend/Dockerfile
|
||||
target: migrate
|
||||
command: ["sh", "-c", "prisma generate && python3 scripts/gen_prisma_types_stub.py && prisma migrate deploy"]
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"prisma generate && python3 scripts/gen_prisma_types_stub.py && prisma migrate deploy",
|
||||
]
|
||||
develop:
|
||||
watch:
|
||||
- path: ./
|
||||
@@ -79,8 +85,8 @@ services:
|
||||
falkordb:
|
||||
image: falkordb/falkordb:latest
|
||||
ports:
|
||||
- "6380:6379" # FalkorDB Redis protocol (6380 to avoid clash with Redis on 6379)
|
||||
- "3001:3000" # FalkorDB web UI
|
||||
- "6380:6379" # FalkorDB Redis protocol (6380 to avoid clash with Redis on 6379)
|
||||
- "3001:3000" # FalkorDB web UI
|
||||
environment:
|
||||
- REDIS_ARGS=--requirepass ${GRAPHITI_FALKORDB_PASSWORD:-}
|
||||
volumes:
|
||||
@@ -88,7 +94,11 @@ services:
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -p 6379 -a \"${GRAPHITI_FALKORDB_PASSWORD:-}\" --no-auth-warning ping && wget --spider -q http://localhost:3000 || exit 1"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'redis-cli -p 6379 -a "${GRAPHITI_FALKORDB_PASSWORD:-}" --no-auth-warning ping && wget --spider -q http://localhost:3000 || exit 1',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -300,19 +310,6 @@ services:
|
||||
condition: service_completed_successfully
|
||||
database_manager:
|
||||
condition: service_started
|
||||
# healthcheck:
|
||||
# test:
|
||||
# [
|
||||
# "CMD",
|
||||
# "curl",
|
||||
# "-f",
|
||||
# "-X",
|
||||
# "POST",
|
||||
# "http://localhost:8003/health_check",
|
||||
# ]
|
||||
# interval: 10s
|
||||
# timeout: 10s
|
||||
# retries: 5
|
||||
<<: *backend-env-files
|
||||
environment:
|
||||
<<: *backend-env
|
||||
|
||||
@@ -193,3 +193,4 @@ services:
|
||||
- copilot_executor
|
||||
- websocket_server
|
||||
- database_manager
|
||||
- scheduler_server
|
||||
|
||||
@@ -81,8 +81,10 @@ Every time a new Front-end dependency is added by you or others, you will need t
|
||||
- `pnpm lint` - Run ESLint and Prettier checks
|
||||
- `pnpm format` - Format code with Prettier
|
||||
- `pnpm types` - Run TypeScript type checking
|
||||
- `pnpm test` - Run Playwright tests
|
||||
- `pnpm test-ui` - Run Playwright tests with UI
|
||||
- `pnpm test:unit` - Run the Vitest integration and unit suite with coverage
|
||||
- `pnpm test` - Run the Playwright E2E suite used in CI
|
||||
- `pnpm test-ui` - Run the same Playwright E2E suite with UI
|
||||
- `pnpm test:e2e:no-build` - Run the same Playwright E2E suite against a running app
|
||||
- `pnpm fetch:openapi` - Fetch OpenAPI spec from backend
|
||||
- `pnpm generate:api-client` - Generate API client from OpenAPI spec
|
||||
- `pnpm generate:api` - Fetch OpenAPI spec and generate API client
|
||||
|
||||
@@ -121,35 +121,49 @@ Only when the component has complex internal logic that is hard to exercise thro
|
||||
### Running
|
||||
|
||||
```bash
|
||||
pnpm test # build + run all Playwright tests
|
||||
pnpm test-ui # run with Playwright UI
|
||||
pnpm test:no-build # run against a running dev server
|
||||
pnpm test # build + run the Playwright E2E suite used in CI
|
||||
pnpm test-ui # run the same E2E suite with Playwright UI
|
||||
pnpm test:e2e:no-build # run the same E2E suite against a running dev server
|
||||
pnpm exec playwright test # run the same eight-spec Playwright suite directly
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
1. Start the backend + Supabase stack:
|
||||
- From `autogpt_platform`: `docker compose --profile local up deps_backend -d`
|
||||
2. Seed rich E2E data (creates `test123@gmail.com` with library agents):
|
||||
2. Seed rich E2E data (creates `test123@example.com` with library agents):
|
||||
- From `autogpt_platform/backend`: `poetry run python test/e2e_test_data.py`
|
||||
|
||||
### How Playwright setup works
|
||||
|
||||
- Playwright runs from `frontend/playwright.config.ts` with a global setup step
|
||||
- Global setup creates a user pool via the real signup UI, stored in `frontend/.auth/user-pool.json`
|
||||
- `getTestUser()` (from `src/tests/utils/auth.ts`) pulls a random user from the pool
|
||||
- Playwright runs from `frontend/playwright.config.ts` and keeps browser-only code in `frontend/src/playwright/`
|
||||
- Global setup creates reusable auth states for deterministic seeded accounts in `frontend/.auth/states/`
|
||||
- `getTestUser()` (from `src/playwright/utils/auth.ts`) picks one seeded account for general auth coverage
|
||||
- `getTestUserWithLibraryAgents()` uses the rich user created by the data script
|
||||
|
||||
### Test users
|
||||
|
||||
- **User pool (basic users)** — created automatically by Playwright global setup. Used by `getTestUser()`
|
||||
- **Seeded E2E accounts** — created by backend fixtures and logged in during Playwright global setup. Used by `getTestUser()` and `E2E_AUTH_STATES`
|
||||
- **Rich user with library agents** — created by `backend/test/e2e_test_data.py`. Used by `getTestUserWithLibraryAgents()`
|
||||
|
||||
### Current Playwright E2E suite
|
||||
|
||||
The CI suite is intentionally limited to the cross-page journeys we still require a real browser for. Playwright discovers the PR-gating specs by the `*-happy-path.spec.ts` naming pattern inside `src/playwright/`:
|
||||
|
||||
- `src/playwright/auth-happy-path.spec.ts`
|
||||
- `src/playwright/settings-happy-path.spec.ts`
|
||||
- `src/playwright/api-keys-happy-path.spec.ts`
|
||||
- `src/playwright/builder-happy-path.spec.ts`
|
||||
- `src/playwright/library-happy-path.spec.ts`
|
||||
- `src/playwright/marketplace-happy-path.spec.ts`
|
||||
- `src/playwright/publish-happy-path.spec.ts`
|
||||
- `src/playwright/copilot-happy-path.spec.ts`
|
||||
|
||||
### Resetting the DB
|
||||
|
||||
If you reset the Docker DB and logins start failing:
|
||||
|
||||
1. Delete `frontend/.auth/user-pool.json`
|
||||
1. Delete `frontend/.auth/states/*` and `frontend/.auth/user-pool.json` if it exists
|
||||
2. Re-run `poetry run python test/e2e_test_data.py`
|
||||
|
||||
## Storybook
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"lint": "next lint && prettier --check .",
|
||||
"format": "next lint --fix; prettier --write .",
|
||||
"types": "tsc --noEmit",
|
||||
"test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test",
|
||||
"test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test --ui",
|
||||
"test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && pnpm test:e2e:no-build",
|
||||
"test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && pnpm test:e2e:ui",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:no-build": "playwright test",
|
||||
"test:e2e": "NEXT_PUBLIC_PW_TEST=true next build --turbo && pnpm test:e2e:no-build",
|
||||
"test:e2e:no-build": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"gentests": "playwright codegen http://localhost:3000",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -7,10 +7,22 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { buildCookieConsentStorageState } from "./src/playwright/credentials/storage-state";
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
dotenv.config({ path: path.resolve(__dirname, "../backend/.env") });
|
||||
|
||||
const frontendRoot = __dirname.replaceAll("\\", "/");
|
||||
const configuredBaseURL =
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
|
||||
const parsedBaseURL = new URL(configuredBaseURL);
|
||||
const baseURL = parsedBaseURL.toString().replace(/\/$/, "");
|
||||
const baseOrigin = parsedBaseURL.origin;
|
||||
const jsonReporterOutputFile = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE;
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number(process.env.PLAYWRIGHT_WORKERS)
|
||||
: process.env.CI
|
||||
? 8
|
||||
: undefined;
|
||||
|
||||
// Directory where CI copies .next/static from the Docker container
|
||||
const staticCoverageDir = path.resolve(__dirname, ".next-static-coverage");
|
||||
@@ -57,17 +69,18 @@ function resolveSourceMap(sourcePath: string) {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./src/tests",
|
||||
testDir: "./src/playwright",
|
||||
testMatch: /.*-happy-path\.spec\.ts/,
|
||||
/* Global setup file that runs before all tests */
|
||||
globalSetup: "./src/tests/global-setup.ts",
|
||||
globalSetup: "./src/playwright/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. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* use more workers on CI. */
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
retries: process.env.CI ? Number(process.env.PLAYWRIGHT_RETRIES ?? 2) : 0,
|
||||
/* Higher worker count keeps PR smoke runtime down without sharing page state. */
|
||||
workers: configuredWorkers,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [
|
||||
["list"],
|
||||
@@ -92,40 +105,25 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
],
|
||||
...(jsonReporterOutputFile
|
||||
? [["json", { outputFile: jsonReporterOutputFile }] as const]
|
||||
: []),
|
||||
],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:3000/",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
screenshot: "only-on-failure",
|
||||
bypassCSP: true,
|
||||
|
||||
/* Helps debugging failures */
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||
video: process.env.CI ? "off" : "retain-on-failure",
|
||||
|
||||
/* Auto-accept cookies in all tests to prevent banner interference */
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: "http://localhost:3000",
|
||||
localStorage: [
|
||||
{
|
||||
name: "autogpt_cookie_consent",
|
||||
value: JSON.stringify({
|
||||
hasConsented: true,
|
||||
timestamp: Date.now(),
|
||||
analytics: true,
|
||||
monitoring: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
storageState: buildCookieConsentStorageState(baseOrigin),
|
||||
},
|
||||
/* Maximum time one test can run for */
|
||||
timeout: 25000,
|
||||
@@ -133,7 +131,7 @@ export default defineConfig({
|
||||
/* Configure web server to start automatically (local dev only) */
|
||||
webServer: {
|
||||
command: "pnpm start",
|
||||
url: "http://localhost:3000",
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ const emptyDashboard: PlatformCostDashboard = {
|
||||
total_cost_microdollars: 0,
|
||||
total_requests: 0,
|
||||
total_users: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
avg_input_tokens_per_request: 0,
|
||||
avg_output_tokens_per_request: 0,
|
||||
avg_cost_microdollars_per_request: 0,
|
||||
cost_p50_microdollars: 0,
|
||||
cost_p75_microdollars: 0,
|
||||
cost_p95_microdollars: 0,
|
||||
cost_p99_microdollars: 0,
|
||||
cost_buckets: [],
|
||||
by_provider: [],
|
||||
by_user: [],
|
||||
};
|
||||
@@ -47,6 +57,20 @@ const dashboardWithData: PlatformCostDashboard = {
|
||||
total_cost_microdollars: 5_000_000,
|
||||
total_requests: 100,
|
||||
total_users: 5,
|
||||
total_input_tokens: 150000,
|
||||
total_output_tokens: 60000,
|
||||
avg_input_tokens_per_request: 2500,
|
||||
avg_output_tokens_per_request: 1000,
|
||||
avg_cost_microdollars_per_request: 83333,
|
||||
cost_p50_microdollars: 50000,
|
||||
cost_p75_microdollars: 100000,
|
||||
cost_p95_microdollars: 250000,
|
||||
cost_p99_microdollars: 500000,
|
||||
cost_buckets: [
|
||||
{ bucket: "$0-0.50", count: 80 },
|
||||
{ bucket: "$0.50-1", count: 15 },
|
||||
{ bucket: "$1-2", count: 5 },
|
||||
],
|
||||
by_provider: [
|
||||
{
|
||||
provider: "openai",
|
||||
@@ -75,6 +99,7 @@ const dashboardWithData: PlatformCostDashboard = {
|
||||
total_input_tokens: 50000,
|
||||
total_output_tokens: 20000,
|
||||
request_count: 60,
|
||||
cost_bearing_request_count: 40,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -134,9 +159,14 @@ describe("PlatformCostContent", () => {
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".animate-pulse")).toBeNull(),
|
||||
);
|
||||
// Verify the two summary cards that show $0.0000 — Known Cost and Estimated Total
|
||||
// Known Cost and Estimated Total cards render $0.0000
|
||||
// "Known Cost" appears in both the SummaryCard and the ProviderTable header
|
||||
expect(screen.getAllByText("Known Cost").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Estimated Total")).toBeDefined();
|
||||
// All cost summary cards (Known Cost, Estimated Total, Avg Cost,
|
||||
// Typical/Upper/High/Peak Cost) show $0.0000
|
||||
const zeroCostItems = screen.getAllByText("$0.0000");
|
||||
expect(zeroCostItems.length).toBe(2);
|
||||
expect(zeroCostItems.length).toBe(7);
|
||||
expect(screen.getByText("No cost data yet")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -155,7 +185,9 @@ describe("PlatformCostContent", () => {
|
||||
);
|
||||
expect(screen.getByText("$5.0000")).toBeDefined();
|
||||
expect(screen.getByText("100")).toBeDefined();
|
||||
expect(screen.getByText("5")).toBeDefined();
|
||||
// "5" appears in multiple places (Active Users card + bucket count),
|
||||
// so verify at least one element renders it.
|
||||
expect(screen.getAllByText("5").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("openai")).toBeDefined();
|
||||
expect(screen.getByText("google_maps")).toBeDefined();
|
||||
});
|
||||
@@ -223,10 +255,83 @@ describe("PlatformCostContent", () => {
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".animate-pulse")).toBeNull(),
|
||||
);
|
||||
// Original 4 cards
|
||||
expect(screen.getAllByText("Known Cost").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Estimated Total")).toBeDefined();
|
||||
expect(screen.getByText("Total Requests")).toBeDefined();
|
||||
expect(screen.getByText("Active Users")).toBeDefined();
|
||||
// New average/token cards
|
||||
expect(screen.getByText("Avg Cost / Request")).toBeDefined();
|
||||
expect(screen.getByText("Avg Input Tokens")).toBeDefined();
|
||||
expect(screen.getByText("Avg Output Tokens")).toBeDefined();
|
||||
expect(screen.getByText("Total Tokens")).toBeDefined();
|
||||
// Percentile cards (friendlier labels)
|
||||
expect(screen.getByText("Typical Cost (P50)")).toBeDefined();
|
||||
expect(screen.getByText("Upper Cost (P75)")).toBeDefined();
|
||||
expect(screen.getByText("High Cost (P95)")).toBeDefined();
|
||||
expect(screen.getByText("Peak Cost (P99)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders cost distribution buckets", async () => {
|
||||
mockUseGetDashboard.mockReturnValue({
|
||||
data: dashboardWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseGetLogs.mockReturnValue({
|
||||
data: logsWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
renderComponent();
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".animate-pulse")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("Cost Distribution by Bucket")).toBeDefined();
|
||||
expect(screen.getByText("$0-0.50")).toBeDefined();
|
||||
expect(screen.getByText("$0.50-1")).toBeDefined();
|
||||
expect(screen.getByText("$1-2")).toBeDefined();
|
||||
expect(screen.getByText("80")).toBeDefined();
|
||||
expect(screen.getByText("15")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders new summary card values from fixture data", async () => {
|
||||
mockUseGetDashboard.mockReturnValue({
|
||||
data: dashboardWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseGetLogs.mockReturnValue({
|
||||
data: logsWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
renderComponent();
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".animate-pulse")).toBeNull(),
|
||||
);
|
||||
// Avg Input Tokens: 2500 formatted
|
||||
expect(screen.getByText("2,500")).toBeDefined();
|
||||
// Avg Output Tokens: 1000 formatted
|
||||
expect(screen.getByText("1,000")).toBeDefined();
|
||||
// P50 cost: 50000 microdollars = $0.0500
|
||||
expect(screen.getByText("$0.0500")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders user table avg cost column with fixture data", async () => {
|
||||
mockUseGetDashboard.mockReturnValue({
|
||||
data: dashboardWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseGetLogs.mockReturnValue({
|
||||
data: logsWithData,
|
||||
isLoading: false,
|
||||
});
|
||||
renderComponent({ tab: "by-user" });
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".animate-pulse")).toBeNull(),
|
||||
);
|
||||
// User table should show Avg Cost / Req header
|
||||
expect(screen.getByText("Avg Cost / Req")).toBeDefined();
|
||||
// Input/Output token columns
|
||||
expect(screen.getByText("Input Tokens")).toBeDefined();
|
||||
expect(screen.getByText("Output Tokens")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders filter inputs", async () => {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
|
||||
import { formatMicrodollars } from "../helpers";
|
||||
import { formatMicrodollars, formatTokens } from "../helpers";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
import { ProviderTable } from "./ProviderTable";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { LogsTable } from "./LogsTable";
|
||||
import { usePlatformCostContent } from "./usePlatformCostContent";
|
||||
import type { CostBucket } from "@/app/api/__generated__/models/costBucket";
|
||||
|
||||
interface Props {
|
||||
searchParams: {
|
||||
@@ -54,6 +55,76 @@ export function PlatformCostContent({ searchParams }: Props) {
|
||||
handleExport,
|
||||
} = usePlatformCostContent(searchParams);
|
||||
|
||||
const summaryCards: { label: string; value: string; subtitle?: string }[] =
|
||||
dashboard
|
||||
? [
|
||||
{
|
||||
label: "Known Cost",
|
||||
value: formatMicrodollars(dashboard.total_cost_microdollars),
|
||||
subtitle: "From providers that report USD cost",
|
||||
},
|
||||
{
|
||||
label: "Estimated Total",
|
||||
value: formatMicrodollars(totalEstimatedCost),
|
||||
subtitle: "Including per-run cost estimates",
|
||||
},
|
||||
{
|
||||
label: "Total Requests",
|
||||
value: dashboard.total_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: "Active Users",
|
||||
value: dashboard.total_users.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: "Avg Cost / Request",
|
||||
value: formatMicrodollars(
|
||||
dashboard.avg_cost_microdollars_per_request ?? 0,
|
||||
),
|
||||
subtitle: "Known cost divided by cost-bearing requests",
|
||||
},
|
||||
{
|
||||
label: "Avg Input Tokens",
|
||||
value: Math.round(
|
||||
dashboard.avg_input_tokens_per_request ?? 0,
|
||||
).toLocaleString(),
|
||||
subtitle: "Prompt tokens per request (context size)",
|
||||
},
|
||||
{
|
||||
label: "Avg Output Tokens",
|
||||
value: Math.round(
|
||||
dashboard.avg_output_tokens_per_request ?? 0,
|
||||
).toLocaleString(),
|
||||
subtitle: "Completion tokens per request (response length)",
|
||||
},
|
||||
{
|
||||
label: "Total Tokens",
|
||||
value: `${formatTokens(dashboard.total_input_tokens ?? 0)} in / ${formatTokens(dashboard.total_output_tokens ?? 0)} out`,
|
||||
subtitle: "Prompt vs completion token split",
|
||||
},
|
||||
{
|
||||
label: "Typical Cost (P50)",
|
||||
value: formatMicrodollars(dashboard.cost_p50_microdollars ?? 0),
|
||||
subtitle: "Median cost per request",
|
||||
},
|
||||
{
|
||||
label: "Upper Cost (P75)",
|
||||
value: formatMicrodollars(dashboard.cost_p75_microdollars ?? 0),
|
||||
subtitle: "75th percentile cost",
|
||||
},
|
||||
{
|
||||
label: "High Cost (P95)",
|
||||
value: formatMicrodollars(dashboard.cost_p95_microdollars ?? 0),
|
||||
subtitle: "95th percentile cost",
|
||||
},
|
||||
{
|
||||
label: "Peak Cost (P99)",
|
||||
value: formatMicrodollars(dashboard.cost_p99_microdollars ?? 0),
|
||||
subtitle: "99th percentile cost",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-4">
|
||||
@@ -204,37 +275,54 @@ export function PlatformCostContent({ searchParams }: Props) {
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{/* 12 skeleton placeholders — one per summary card */}
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-32 rounded-lg" />
|
||||
<Skeleton className="h-8 w-48 rounded" />
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{dashboard && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<SummaryCard
|
||||
label="Known Cost"
|
||||
value={formatMicrodollars(dashboard.total_cost_microdollars)}
|
||||
subtitle="From providers that report USD cost"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Estimated Total"
|
||||
value={formatMicrodollars(totalEstimatedCost)}
|
||||
subtitle="Including per-run cost estimates"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total Requests"
|
||||
value={dashboard.total_requests.toLocaleString()}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Active Users"
|
||||
value={dashboard.total_users.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<SummaryCard
|
||||
key={card.label}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
subtitle={card.subtitle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dashboard.cost_buckets && dashboard.cost_buckets.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
Cost Distribution by Bucket
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-6">
|
||||
{dashboard.cost_buckets.map((b: CostBucket) => (
|
||||
<div
|
||||
key={b.bucket}
|
||||
className="flex flex-col items-center rounded border p-2 text-center"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{b.bucket}
|
||||
</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{b.count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
defaultRateFor,
|
||||
estimateCostForRow,
|
||||
formatMicrodollars,
|
||||
formatTokens,
|
||||
rateKey,
|
||||
rateUnitLabel,
|
||||
trackingValue,
|
||||
@@ -33,6 +34,20 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) {
|
||||
<th scope="col" className="px-4 py-3 text-right">
|
||||
Usage
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right"
|
||||
title="Only populated for token-tracking providers (e.g. LLM calls). Non-token rows (per_run, characters, etc.) show —."
|
||||
>
|
||||
Input Tokens
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right"
|
||||
title="Only populated for token-tracking providers (e.g. LLM calls). Non-token rows (per_run, characters, etc.) show —."
|
||||
>
|
||||
Output Tokens
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">
|
||||
Requests
|
||||
</th>
|
||||
@@ -74,6 +89,16 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) {
|
||||
<TrackingBadge trackingType={row.tracking_type} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">{trackingValue(row)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.total_input_tokens > 0
|
||||
? formatTokens(row.total_input_tokens)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.total_output_tokens > 0
|
||||
? formatTokens(row.total_output_tokens)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.request_count.toLocaleString()}
|
||||
</td>
|
||||
@@ -124,7 +149,7 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) {
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={8}
|
||||
colSpan={10}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No cost data yet
|
||||
|
||||
@@ -27,10 +27,7 @@ function UserTable({ data }: Props) {
|
||||
Output Tokens
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">
|
||||
Cache Read
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">
|
||||
Cache Write
|
||||
Avg Cost / Req
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -61,13 +58,12 @@ function UserTable({ data }: Props) {
|
||||
{formatTokens(row.total_output_tokens)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(row.total_cache_read_tokens ?? 0) > 0
|
||||
? formatTokens(row.total_cache_read_tokens ?? 0)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(row.total_cache_creation_tokens ?? 0) > 0
|
||||
? formatTokens(row.total_cache_creation_tokens ?? 0)
|
||||
{(row.cost_bearing_request_count ?? 0) > 0 &&
|
||||
row.total_cost_microdollars > 0
|
||||
? formatMicrodollars(
|
||||
row.total_cost_microdollars /
|
||||
(row.cost_bearing_request_count ?? 1),
|
||||
)
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -75,7 +71,7 @@ function UserTable({ data }: Props) {
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
colSpan={6}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No cost data yet
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
getGetV2GetSpecificAgentMockHandler,
|
||||
getGetV2GetSpecificAgentResponseMock,
|
||||
getGetV2ListStoreAgentsMockHandler,
|
||||
getGetV2ListStoreAgentsResponseMock,
|
||||
} from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { MainAgentPage } from "../MainAgentPage";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseSupabase = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
|
||||
useSupabase: mockUseSupabase,
|
||||
}));
|
||||
|
||||
describe("MainAgentPage", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSupabase.mockReturnValue({
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the marketplace agent details and related sections", async () => {
|
||||
const agentDetails = getGetV2GetSpecificAgentResponseMock({
|
||||
agent_name: "Deterministic Agent",
|
||||
creator: "AutoGPT",
|
||||
creator_avatar: "",
|
||||
sub_heading: "A stable marketplace listing",
|
||||
description: "This agent is used for integration coverage.",
|
||||
categories: ["demo", "test"],
|
||||
versions: ["1", "2"],
|
||||
active_version_id: "store-version-1",
|
||||
store_listing_version_id: "listing-1",
|
||||
agent_image: ["https://example.com/agent.png"],
|
||||
agent_output_demo: "",
|
||||
agent_video: "",
|
||||
});
|
||||
const otherAgents = getGetV2ListStoreAgentsResponseMock({
|
||||
agents: [
|
||||
{
|
||||
...getGetV2ListStoreAgentsResponseMock().agents[0],
|
||||
slug: "other-agent",
|
||||
agent_name: "Other Agent",
|
||||
creator: "AutoGPT",
|
||||
},
|
||||
],
|
||||
});
|
||||
const similarAgents = getGetV2ListStoreAgentsResponseMock({
|
||||
agents: [
|
||||
{
|
||||
...getGetV2ListStoreAgentsResponseMock().agents[0],
|
||||
slug: "similar-agent",
|
||||
agent_name: "Similar Agent",
|
||||
creator: "Another Creator",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
server.use(
|
||||
getGetV2GetSpecificAgentMockHandler(agentDetails),
|
||||
getGetV2ListStoreAgentsMockHandler(({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.searchParams.get("creator") === "autogpt") {
|
||||
return otherAgents;
|
||||
}
|
||||
|
||||
if (url.searchParams.get("search_query") === "deterministic agent") {
|
||||
return similarAgents;
|
||||
}
|
||||
|
||||
return getGetV2ListStoreAgentsResponseMock({ agents: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<MainAgentPage
|
||||
params={{ creator: "autogpt", slug: "deterministic-agent" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((await screen.findByTestId("agent-title")).textContent).toContain(
|
||||
"Deterministic Agent",
|
||||
);
|
||||
expect(screen.getByTestId("agent-description").textContent).toContain(
|
||||
"This agent is used for integration coverage.",
|
||||
);
|
||||
expect(screen.getByTestId("agent-creator").textContent).toContain(
|
||||
"AutoGPT",
|
||||
);
|
||||
expect(screen.getByText("Other agents by AutoGPT")).toBeDefined();
|
||||
expect(screen.getByText("Similar agents")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,64 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
getGetV2ListStoreAgentsResponseMock,
|
||||
getGetV2ListStoreCreatorsResponseMock,
|
||||
} from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { MainMarkeplacePage } from "../MainMarketplacePage";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { getDeleteV2DeleteStoreSubmissionMockHandler422 } from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Only for CI testing purpose, will remove it in future PR
|
||||
test("MainMarketplacePage", async () => {
|
||||
server.use(getDeleteV2DeleteStoreSubmissionMockHandler422());
|
||||
const mockUseMainMarketplacePage = vi.hoisted(() => vi.fn());
|
||||
|
||||
render(<MainMarkeplacePage />);
|
||||
expect(
|
||||
await screen.findByText("Featured agents", { exact: false }),
|
||||
).toBeDefined();
|
||||
vi.mock("../useMainMarketplacePage", () => ({
|
||||
useMainMarketplacePage: mockUseMainMarketplacePage,
|
||||
}));
|
||||
|
||||
describe("MainMarketplacePage", () => {
|
||||
beforeEach(() => {
|
||||
mockUseMainMarketplacePage.mockReturnValue({
|
||||
featuredAgents: getGetV2ListStoreAgentsResponseMock({
|
||||
agents: [
|
||||
{
|
||||
...getGetV2ListStoreAgentsResponseMock().agents[0],
|
||||
slug: "featured-agent",
|
||||
agent_name: "Featured Agent",
|
||||
creator: "AutoGPT",
|
||||
},
|
||||
],
|
||||
}),
|
||||
topAgents: getGetV2ListStoreAgentsResponseMock({
|
||||
agents: [
|
||||
{
|
||||
...getGetV2ListStoreAgentsResponseMock().agents[0],
|
||||
slug: "top-agent",
|
||||
agent_name: "Top Agent",
|
||||
creator: "AutoGPT",
|
||||
},
|
||||
],
|
||||
}),
|
||||
featuredCreators: getGetV2ListStoreCreatorsResponseMock({
|
||||
creators: [
|
||||
{
|
||||
...getGetV2ListStoreCreatorsResponseMock().creators[0],
|
||||
name: "Creator One",
|
||||
username: "creator-one",
|
||||
},
|
||||
],
|
||||
}),
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders featured agents, all agents, and creators", () => {
|
||||
render(<MainMarkeplacePage />);
|
||||
|
||||
expect(screen.getByText(/Featured agents/i)).toBeDefined();
|
||||
expect(screen.getByText("Featured Agent")).toBeDefined();
|
||||
expect(screen.getByText("All Agents")).toBeDefined();
|
||||
expect(screen.getAllByText("Top Agent").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Creator One")).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Become a Creator" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import {
|
||||
getGetV2GetCreatorDetailsResponseMock,
|
||||
getGetV2ListStoreAgentsResponseMock,
|
||||
} from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { MainCreatorPage } from "../MainCreatorPage";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseMainCreatorPage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../useMainCreatorPage", () => ({
|
||||
useMainCreatorPage: mockUseMainCreatorPage,
|
||||
}));
|
||||
|
||||
describe("MainCreatorPage", () => {
|
||||
beforeEach(() => {
|
||||
const creator = getGetV2GetCreatorDetailsResponseMock({
|
||||
name: "Creator One",
|
||||
username: "creator-one",
|
||||
description: "Creator profile used for integration coverage.",
|
||||
avatar_url: "",
|
||||
top_categories: ["automation", "productivity"],
|
||||
links: ["https://example.com/creator"],
|
||||
});
|
||||
|
||||
const creatorAgents = getGetV2ListStoreAgentsResponseMock({
|
||||
agents: [
|
||||
{
|
||||
...getGetV2ListStoreAgentsResponseMock().agents[0],
|
||||
slug: "creator-agent",
|
||||
agent_name: "Creator Agent",
|
||||
creator: "Creator One",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockUseMainCreatorPage.mockReturnValue({
|
||||
creatorAgents,
|
||||
creator,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders creator details and their agents", () => {
|
||||
render(<MainCreatorPage params={{ creator: "creator-one" }} />);
|
||||
|
||||
expect(screen.getByTestId("creator-title").textContent).toContain(
|
||||
"Creator One",
|
||||
);
|
||||
expect(screen.getByTestId("creator-description").textContent).toContain(
|
||||
"Creator profile used for integration coverage.",
|
||||
);
|
||||
expect(screen.getByText("Agents by Creator One")).toBeDefined();
|
||||
expect(screen.getAllByText("Creator Agent").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import {
|
||||
getGetV2GetUserProfileMockHandler,
|
||||
getPostV2UpdateUserProfileMockHandler,
|
||||
} from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import UserProfilePage from "../page";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseSupabase = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
|
||||
useSupabase: mockUseSupabase,
|
||||
}));
|
||||
|
||||
const testUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: "authenticated",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("UserProfilePage", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSupabase.mockReturnValue({
|
||||
user: testUser,
|
||||
isLoggedIn: true,
|
||||
isUserLoading: false,
|
||||
supabase: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the existing profile and saves changes", async () => {
|
||||
let profile = {
|
||||
name: "Original Name",
|
||||
username: "original-user",
|
||||
description: "Original bio",
|
||||
links: ["https://example.com/1", "", "", "", ""],
|
||||
avatar_url: "",
|
||||
is_featured: false,
|
||||
};
|
||||
|
||||
server.use(
|
||||
getGetV2GetUserProfileMockHandler(() => profile),
|
||||
getPostV2UpdateUserProfileMockHandler(async ({ request }) => {
|
||||
profile = (await request.json()) as typeof profile;
|
||||
return profile;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UserProfilePage />);
|
||||
|
||||
const displayName = await screen.findByLabelText("Display name");
|
||||
const handle = screen.getByLabelText("Handle");
|
||||
const bio = screen.getByLabelText("Bio");
|
||||
|
||||
expect((displayName as HTMLInputElement).value).toBe("Original Name");
|
||||
expect((handle as HTMLInputElement).value).toBe("original-user");
|
||||
|
||||
fireEvent.change(displayName, { target: { value: "Updated Name" } });
|
||||
fireEvent.change(handle, { target: { value: "updated-user" } });
|
||||
fireEvent.change(bio, { target: { value: "Updated bio" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(profile.name).toBe("Updated Name");
|
||||
expect(profile.username).toBe("updated-user");
|
||||
expect(profile.description).toBe("Updated bio");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import {
|
||||
getDeleteV1RevokeApiKeyMockHandler,
|
||||
getGetV1ListUserApiKeysMockHandler,
|
||||
getPostV1CreateNewApiKeyMockHandler,
|
||||
} from "@/app/api/__generated__/endpoints/api-keys/api-keys.msw";
|
||||
import { APIKeyPermission } from "@/app/api/__generated__/models/aPIKeyPermission";
|
||||
import { APIKeyStatus } from "@/app/api/__generated__/models/aPIKeyStatus";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import ApiKeysPage from "../page";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
type ApiKeyRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
head: string;
|
||||
tail: string;
|
||||
status: APIKeyStatus;
|
||||
};
|
||||
|
||||
function toApiKeyResponse(key: ApiKeyRecord) {
|
||||
return {
|
||||
id: key.id,
|
||||
user_id: "user-1",
|
||||
scopes: [APIKeyPermission.EXECUTE_GRAPH],
|
||||
type: "api_key" as const,
|
||||
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||
expires_at: null,
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
name: key.name,
|
||||
head: key.head,
|
||||
tail: key.tail,
|
||||
status: key.status,
|
||||
description: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ApiKeysPage", () => {
|
||||
let apiKeys: ApiKeyRecord[];
|
||||
let revokedKeyId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
apiKeys = [];
|
||||
revokedKeyId = "";
|
||||
|
||||
server.use(
|
||||
getGetV1ListUserApiKeysMockHandler(() =>
|
||||
apiKeys.map((key) => toApiKeyResponse(key)),
|
||||
),
|
||||
getPostV1CreateNewApiKeyMockHandler(async ({ request }) => {
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions?: APIKeyPermission[];
|
||||
};
|
||||
|
||||
const createdKey: ApiKeyRecord = {
|
||||
id: `key-${apiKeys.length + 1}`,
|
||||
name: body.name,
|
||||
head: "head",
|
||||
tail: "tail",
|
||||
status: APIKeyStatus.ACTIVE,
|
||||
};
|
||||
|
||||
apiKeys = [...apiKeys, createdKey];
|
||||
|
||||
return {
|
||||
api_key: toApiKeyResponse(createdKey),
|
||||
plain_text_key: "plain-text-key",
|
||||
};
|
||||
}),
|
||||
getDeleteV1RevokeApiKeyMockHandler(({ params }) => {
|
||||
const keyId = String(params.keyId);
|
||||
const removedKey = apiKeys.find((key) => key.id === keyId);
|
||||
|
||||
revokedKeyId = keyId;
|
||||
apiKeys = apiKeys.filter((key) => key.id !== keyId);
|
||||
|
||||
return toApiKeyResponse(
|
||||
removedKey ?? {
|
||||
id: keyId,
|
||||
name: "Unknown key",
|
||||
head: "head",
|
||||
tail: "tail",
|
||||
status: APIKeyStatus.REVOKED,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("creates a new API key", async () => {
|
||||
render(<ApiKeysPage />);
|
||||
|
||||
fireEvent.click(await screen.findByText("Create Key"));
|
||||
fireEvent.change(screen.getByLabelText("Name"), {
|
||||
target: { value: "CLI Key" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("AutoGPT Platform API Key Created"),
|
||||
).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiKeys[0]?.name).toBe("CLI Key");
|
||||
});
|
||||
});
|
||||
|
||||
test("revokes an existing API key", async () => {
|
||||
apiKeys = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "Existing Key",
|
||||
head: "head",
|
||||
tail: "tail",
|
||||
status: APIKeyStatus.ACTIVE,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ApiKeysPage />);
|
||||
|
||||
expect(await screen.findByText("Existing Key")).toBeDefined();
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId("api-key-actions"));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Revoke" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(revokedKeyId).toBe("key-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { getGetV2ListMySubmissionsResponseMock } from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
|
||||
import { AgentTableRow } from "../AgentTableRow";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
function makeSubmission(status: SubmissionStatus) {
|
||||
const submission = getGetV2ListMySubmissionsResponseMock().submissions[0];
|
||||
|
||||
return {
|
||||
...submission,
|
||||
graph_id: "graph-1",
|
||||
graph_version: 7,
|
||||
listing_version_id: `listing-${status.toLowerCase()}`,
|
||||
name: `Agent ${status}`,
|
||||
description: `Description ${status}`,
|
||||
status,
|
||||
image_urls: [],
|
||||
submitted_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentTableRow", () => {
|
||||
const onViewSubmission = vi.fn();
|
||||
const onDeleteSubmission = vi.fn();
|
||||
const onEditSubmission = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onViewSubmission.mockReset();
|
||||
onDeleteSubmission.mockReset();
|
||||
onEditSubmission.mockReset();
|
||||
});
|
||||
|
||||
test("shows edit and delete actions for pending submissions", async () => {
|
||||
render(
|
||||
<AgentTableRow
|
||||
storeAgentSubmission={makeSubmission(SubmissionStatus.PENDING)}
|
||||
onViewSubmission={onViewSubmission}
|
||||
onDeleteSubmission={onDeleteSubmission}
|
||||
onEditSubmission={onEditSubmission}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId("agent-table-row-actions"));
|
||||
|
||||
fireEvent.click(await screen.findByText("Edit"));
|
||||
expect(onEditSubmission).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId("agent-table-row-actions"));
|
||||
fireEvent.click(await screen.findByText("Delete"));
|
||||
expect(onDeleteSubmission).toHaveBeenCalledWith("listing-pending");
|
||||
expect(onViewSubmission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows view only for non-pending submissions", async () => {
|
||||
const approvedSubmission = makeSubmission(SubmissionStatus.APPROVED);
|
||||
|
||||
render(
|
||||
<AgentTableRow
|
||||
storeAgentSubmission={approvedSubmission}
|
||||
onViewSubmission={onViewSubmission}
|
||||
onDeleteSubmission={onDeleteSubmission}
|
||||
onEditSubmission={onEditSubmission}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId("agent-table-row-actions"));
|
||||
|
||||
const viewAction = await screen.findByText("View");
|
||||
fireEvent.click(viewAction);
|
||||
|
||||
expect(onViewSubmission).toHaveBeenCalledWith(approvedSubmission);
|
||||
expect(screen.queryByText("Edit")).toBeNull();
|
||||
expect(screen.queryByText("Delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import {
|
||||
getGetV1GetNotificationPreferencesMockHandler,
|
||||
getGetV1GetUserTimezoneMockHandler,
|
||||
getPostV1UpdateNotificationPreferencesMockHandler,
|
||||
getPostV1UpdateUserEmailMockHandler,
|
||||
} from "@/app/api/__generated__/endpoints/auth/auth.msw";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import SettingsPage from "../page";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseSupabase = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
|
||||
useSupabase: mockUseSupabase,
|
||||
}));
|
||||
|
||||
const testUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: "authenticated",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSupabase.mockReturnValue({
|
||||
user: testUser,
|
||||
isLoggedIn: true,
|
||||
isUserLoading: false,
|
||||
supabase: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the account actions", async () => {
|
||||
server.use(
|
||||
getGetV1GetNotificationPreferencesMockHandler({
|
||||
user_id: "user-1",
|
||||
email: "user@example.com",
|
||||
preferences: {
|
||||
AGENT_RUN: true,
|
||||
ZERO_BALANCE: false,
|
||||
LOW_BALANCE: false,
|
||||
BLOCK_EXECUTION_FAILED: true,
|
||||
CONTINUOUS_AGENT_ERROR: false,
|
||||
DAILY_SUMMARY: false,
|
||||
WEEKLY_SUMMARY: true,
|
||||
MONTHLY_SUMMARY: false,
|
||||
AGENT_APPROVED: true,
|
||||
AGENT_REJECTED: true,
|
||||
},
|
||||
daily_limit: 0,
|
||||
emails_sent_today: 0,
|
||||
last_reset_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
getGetV1GetUserTimezoneMockHandler({ timezone: "Asia/Kolkata" }),
|
||||
getPostV1UpdateUserEmailMockHandler({}),
|
||||
getPostV1UpdateNotificationPreferencesMockHandler({
|
||||
user_id: "user-1",
|
||||
email: "user@example.com",
|
||||
preferences: {},
|
||||
daily_limit: 0,
|
||||
emails_sent_today: 0,
|
||||
last_reset_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
const emailInput = await screen.findByLabelText("Email");
|
||||
expect((emailInput as HTMLInputElement).value).toBe("user@example.com");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Reset password" }).getAttribute("href"),
|
||||
).toBe("/reset-password");
|
||||
});
|
||||
|
||||
test("saves notification preference changes", async () => {
|
||||
let submittedPreferences:
|
||||
| {
|
||||
email: string;
|
||||
preferences: Record<string, boolean>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
server.use(
|
||||
getGetV1GetNotificationPreferencesMockHandler({
|
||||
user_id: "user-1",
|
||||
email: "user@example.com",
|
||||
preferences: {
|
||||
AGENT_RUN: false,
|
||||
ZERO_BALANCE: false,
|
||||
LOW_BALANCE: false,
|
||||
BLOCK_EXECUTION_FAILED: false,
|
||||
CONTINUOUS_AGENT_ERROR: false,
|
||||
DAILY_SUMMARY: false,
|
||||
WEEKLY_SUMMARY: false,
|
||||
MONTHLY_SUMMARY: false,
|
||||
AGENT_APPROVED: false,
|
||||
AGENT_REJECTED: false,
|
||||
},
|
||||
daily_limit: 0,
|
||||
emails_sent_today: 0,
|
||||
last_reset_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
getGetV1GetUserTimezoneMockHandler({ timezone: "Asia/Kolkata" }),
|
||||
getPostV1UpdateUserEmailMockHandler({}),
|
||||
getPostV1UpdateNotificationPreferencesMockHandler(async ({ request }) => {
|
||||
submittedPreferences = (await request.json()) as {
|
||||
email: string;
|
||||
preferences: Record<string, boolean>;
|
||||
};
|
||||
|
||||
return {
|
||||
user_id: "user-1",
|
||||
email: submittedPreferences.email,
|
||||
preferences: submittedPreferences.preferences,
|
||||
daily_limit: 0,
|
||||
emails_sent_today: 0,
|
||||
last_reset_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("switch", { name: "Agent Run Notifications" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preferences" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submittedPreferences?.preferences.AGENT_RUN).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import type { ReactNode } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { EmailForm } from "../EmailForm";
|
||||
|
||||
const mockToast = vi.hoisted(() => vi.fn());
|
||||
const mockMutateAsync = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/molecules/Toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/__generated__/endpoints/auth/auth", () => ({
|
||||
usePostV1UpdateUserEmail: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const testUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: "authenticated",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
} as User;
|
||||
|
||||
describe("EmailForm", () => {
|
||||
beforeEach(() => {
|
||||
mockToast.mockReset();
|
||||
mockMutateAsync.mockReset();
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("submits a changed email to both update endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<EmailForm user={testUser} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Email"), {
|
||||
target: { value: "updated@example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update email" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/auth/user", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "updated@example.com" }),
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
data: "updated@example.com",
|
||||
});
|
||||
});
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Successfully updated email",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps submit disabled when the email has not changed", () => {
|
||||
render(<EmailForm user={testUser} />);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "Update email",
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ export function NotificationForm({ preferences, user }: NotificationFormProps) {
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
aria-label="Agent Run Notifications"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import SignupPage from "../page";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseSupabase = vi.hoisted(() => vi.fn());
|
||||
const mockSignupAction = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
|
||||
useSupabase: mockUseSupabase,
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
signup: mockSignupAction,
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSupabase.mockReturnValue({
|
||||
supabase: {},
|
||||
user: null,
|
||||
isUserLoading: false,
|
||||
isLoggedIn: false,
|
||||
});
|
||||
mockSignupAction.mockReset();
|
||||
});
|
||||
|
||||
test("shows existing user feedback from signup action", async () => {
|
||||
mockSignupAction.mockResolvedValue({
|
||||
success: false,
|
||||
error: "user_already_exists",
|
||||
});
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Email"), {
|
||||
target: { value: "existing@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Password", { selector: "input" }), {
|
||||
target: { value: "validpassword123" },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("Confirm Password", { selector: "input" }),
|
||||
{
|
||||
target: { value: "validpassword123" },
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign up" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignupAction).toHaveBeenCalledWith(
|
||||
"existing@example.com",
|
||||
"validpassword123",
|
||||
"validpassword123",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("User with this email already exists"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.13.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 { ResponseType } from "./responseType";
|
||||
import type { BlockOutputResponseSessionId } from "./blockOutputResponseSessionId";
|
||||
import type { BlockOutputResponseOutputs } from "./blockOutputResponseOutputs";
|
||||
import type { BlockOutputResponseIsDryRun } from "./blockOutputResponseIsDryRun";
|
||||
|
||||
/**
|
||||
* Response for run_block tool.
|
||||
*/
|
||||
export interface BlockOutputResponse {
|
||||
type?: ResponseType;
|
||||
message: string;
|
||||
session_id?: BlockOutputResponseSessionId;
|
||||
block_id: string;
|
||||
block_name: string;
|
||||
outputs: BlockOutputResponseOutputs;
|
||||
success?: boolean;
|
||||
is_dry_run?: BlockOutputResponseIsDryRun;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.13.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 { GraphExecutionMetaInputs } from "./graphExecutionMetaInputs";
|
||||
import type { GraphExecutionMetaCredentialInputs } from "./graphExecutionMetaCredentialInputs";
|
||||
import type { GraphExecutionMetaNodesInputMasks } from "./graphExecutionMetaNodesInputMasks";
|
||||
import type { GraphExecutionMetaPresetId } from "./graphExecutionMetaPresetId";
|
||||
import type { AgentExecutionStatus } from "./agentExecutionStatus";
|
||||
import type { GraphExecutionMetaStartedAt } from "./graphExecutionMetaStartedAt";
|
||||
import type { GraphExecutionMetaEndedAt } from "./graphExecutionMetaEndedAt";
|
||||
import type { GraphExecutionMetaShareToken } from "./graphExecutionMetaShareToken";
|
||||
import type { GraphExecutionMetaStats } from "./graphExecutionMetaStats";
|
||||
|
||||
export interface GraphExecutionMeta {
|
||||
id: string;
|
||||
user_id: string;
|
||||
graph_id: string;
|
||||
graph_version: number;
|
||||
inputs: GraphExecutionMetaInputs;
|
||||
credential_inputs: GraphExecutionMetaCredentialInputs;
|
||||
nodes_input_masks: GraphExecutionMetaNodesInputMasks;
|
||||
preset_id: GraphExecutionMetaPresetId;
|
||||
status: AgentExecutionStatus;
|
||||
/** When execution started running. Null if not yet started (QUEUED). */
|
||||
started_at?: GraphExecutionMetaStartedAt;
|
||||
/** When execution finished. Null if not yet completed (QUEUED, RUNNING, INCOMPLETE, REVIEW). */
|
||||
ended_at?: GraphExecutionMetaEndedAt;
|
||||
is_shared?: boolean;
|
||||
share_token?: GraphExecutionMetaShareToken;
|
||||
is_dry_run?: boolean;
|
||||
stats: GraphExecutionMetaStats;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.13.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 { SuggestedTheme } from "./suggestedTheme";
|
||||
|
||||
/**
|
||||
* Response model for user-specific suggested prompts grouped by theme.
|
||||
*/
|
||||
export interface SuggestedPromptsResponse {
|
||||
themes: SuggestedTheme[];
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.13.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
|
||||
*/
|
||||
|
||||
/**
|
||||
* A themed group of suggested prompts.
|
||||
*/
|
||||
export interface SuggestedTheme {
|
||||
name: string;
|
||||
prompts: string[];
|
||||
}
|
||||
@@ -9123,6 +9123,15 @@
|
||||
],
|
||||
"title": "ContentType"
|
||||
},
|
||||
"CostBucket": {
|
||||
"properties": {
|
||||
"bucket": { "type": "string", "title": "Bucket" },
|
||||
"count": { "type": "integer", "title": "Count" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["bucket", "count"],
|
||||
"title": "CostBucket"
|
||||
},
|
||||
"CostLogRow": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
@@ -12141,7 +12150,58 @@
|
||||
"title": "Total Cost Microdollars"
|
||||
},
|
||||
"total_requests": { "type": "integer", "title": "Total Requests" },
|
||||
"total_users": { "type": "integer", "title": "Total Users" }
|
||||
"total_users": { "type": "integer", "title": "Total Users" },
|
||||
"total_input_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Total Input Tokens",
|
||||
"default": 0
|
||||
},
|
||||
"total_output_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Total Output Tokens",
|
||||
"default": 0
|
||||
},
|
||||
"avg_input_tokens_per_request": {
|
||||
"type": "number",
|
||||
"title": "Avg Input Tokens Per Request",
|
||||
"default": 0.0
|
||||
},
|
||||
"avg_output_tokens_per_request": {
|
||||
"type": "number",
|
||||
"title": "Avg Output Tokens Per Request",
|
||||
"default": 0.0
|
||||
},
|
||||
"avg_cost_microdollars_per_request": {
|
||||
"type": "number",
|
||||
"title": "Avg Cost Microdollars Per Request",
|
||||
"default": 0.0
|
||||
},
|
||||
"cost_p50_microdollars": {
|
||||
"type": "number",
|
||||
"title": "Cost P50 Microdollars",
|
||||
"default": 0.0
|
||||
},
|
||||
"cost_p75_microdollars": {
|
||||
"type": "number",
|
||||
"title": "Cost P75 Microdollars",
|
||||
"default": 0.0
|
||||
},
|
||||
"cost_p95_microdollars": {
|
||||
"type": "number",
|
||||
"title": "Cost P95 Microdollars",
|
||||
"default": 0.0
|
||||
},
|
||||
"cost_p99_microdollars": {
|
||||
"type": "number",
|
||||
"title": "Cost P99 Microdollars",
|
||||
"default": 0.0
|
||||
},
|
||||
"cost_buckets": {
|
||||
"items": { "$ref": "#/components/schemas/CostBucket" },
|
||||
"type": "array",
|
||||
"title": "Cost Buckets",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -15589,7 +15649,12 @@
|
||||
"title": "Total Cache Creation Tokens",
|
||||
"default": 0
|
||||
},
|
||||
"request_count": { "type": "integer", "title": "Request Count" }
|
||||
"request_count": { "type": "integer", "title": "Request Count" },
|
||||
"cost_bearing_request_count": {
|
||||
"type": "integer",
|
||||
"title": "Cost Bearing Request Count",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import {
|
||||
getPostV2UpdateUserProfileMockHandler200,
|
||||
getPostV2UpdateUserProfileMockHandler422,
|
||||
getPostV2UpdateUserProfileResponseMock422,
|
||||
} from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import type { ProfileDetails } from "@/app/api/__generated__/models/profileDetails";
|
||||
import { ProfileInfoForm } from "../ProfileInfoForm";
|
||||
|
||||
function makeProfile(overrides: Partial<ProfileDetails> = {}): ProfileDetails {
|
||||
return {
|
||||
name: "Initial Name",
|
||||
username: "initial-user",
|
||||
description: "Initial description",
|
||||
links: [],
|
||||
avatar_url: "",
|
||||
...overrides,
|
||||
} as ProfileDetails;
|
||||
}
|
||||
|
||||
describe("ProfileInfoForm", () => {
|
||||
it("renders the existing profile values into editable fields", () => {
|
||||
render(<ProfileInfoForm profile={makeProfile({ name: "Hello World" })} />);
|
||||
const nameInput = screen.getByTestId(
|
||||
"profile-info-form-display-name",
|
||||
) as HTMLInputElement;
|
||||
expect(nameInput.defaultValue).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("submits the new display name to POST /api/store/profile and reflects the response", async () => {
|
||||
let receivedBody: Record<string, unknown> | null = null;
|
||||
|
||||
server.use(
|
||||
getPostV2UpdateUserProfileMockHandler200(async ({ request }) => {
|
||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||
return makeProfile({ name: receivedBody?.name as string });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ProfileInfoForm profile={makeProfile({ name: "Old Name" })} />);
|
||||
|
||||
const nameInput = screen.getByTestId("profile-info-form-display-name");
|
||||
fireEvent.change(nameInput, { target: { value: "Brand New Name" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
receivedBody,
|
||||
"POST /api/store/profile must fire when the user clicks Save",
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(receivedBody!.name).toBe("Brand New Name");
|
||||
});
|
||||
|
||||
it("does not silently swallow the request when the API returns 422", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
getPostV2UpdateUserProfileMockHandler422(() => {
|
||||
calls += 1;
|
||||
return getPostV2UpdateUserProfileResponseMock422({
|
||||
detail: [
|
||||
{
|
||||
loc: ["body", "name"],
|
||||
msg: "validation error",
|
||||
type: "value_error",
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ProfileInfoForm profile={makeProfile()} />);
|
||||
|
||||
const nameInput = screen.getByTestId("profile-info-form-display-name");
|
||||
fireEvent.change(nameInput, { target: { value: "Anything" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
calls,
|
||||
"save click must hit the backend even when validation fails",
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { AgentActivityDropdown } from "../AgentActivityDropdown";
|
||||
import { AgentExecutionWithInfo } from "../helpers";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUseAgentActivityDropdown = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../useAgentActivityDropdown", () => ({
|
||||
useAgentActivityDropdown: mockUseAgentActivityDropdown,
|
||||
}));
|
||||
|
||||
function makeExecution(
|
||||
overrides: Partial<AgentExecutionWithInfo> = {},
|
||||
): AgentExecutionWithInfo {
|
||||
return {
|
||||
id: "exec-1",
|
||||
graph_id: "graph-1",
|
||||
status: AgentExecutionStatus.RUNNING,
|
||||
started_at: new Date(),
|
||||
ended_at: null,
|
||||
user_id: "user-1",
|
||||
graph_version: 1,
|
||||
inputs: {},
|
||||
credential_inputs: {},
|
||||
nodes_input_masks: {},
|
||||
preset_id: null,
|
||||
stats: null,
|
||||
agent_name: "Test Agent",
|
||||
agent_description: "A running agent",
|
||||
library_agent_id: "library-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentActivityDropdown", () => {
|
||||
beforeEach(() => {
|
||||
mockUseAgentActivityDropdown.mockReturnValue({
|
||||
activeExecutions: [makeExecution(), makeExecution({ id: "exec-2" })],
|
||||
recentCompletions: [],
|
||||
recentFailures: [],
|
||||
totalCount: 2,
|
||||
isReady: true,
|
||||
error: null,
|
||||
isOpen: false,
|
||||
setIsOpen: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the active execution badge count", () => {
|
||||
render(<AgentActivityDropdown />);
|
||||
|
||||
expect(screen.getByTestId("agent-activity-badge").textContent).toContain(
|
||||
"2",
|
||||
);
|
||||
expect(screen.getByTestId("agent-activity-button")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders the dropdown content when open", async () => {
|
||||
mockUseAgentActivityDropdown.mockReturnValue({
|
||||
activeExecutions: [makeExecution()],
|
||||
recentCompletions: [],
|
||||
recentFailures: [],
|
||||
totalCount: 1,
|
||||
isReady: true,
|
||||
error: null,
|
||||
isOpen: true,
|
||||
setIsOpen: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AgentActivityDropdown />);
|
||||
|
||||
expect(screen.getByTestId("agent-activity-dropdown")).toBeDefined();
|
||||
expect(await screen.findByText("Test Agent")).toBeDefined();
|
||||
});
|
||||
});
|
||||
97
autogpt_platform/frontend/src/lib/utils.test.ts
Normal file
97
autogpt_platform/frontend/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setNestedProperty } from "./utils";
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: "simple property assignment",
|
||||
path: "name",
|
||||
value: "John",
|
||||
expected: { name: "John" },
|
||||
},
|
||||
{
|
||||
name: "nested property with dot notation",
|
||||
path: "user.settings.theme",
|
||||
value: "dark",
|
||||
expected: { user: { settings: { theme: "dark" } } },
|
||||
},
|
||||
{
|
||||
name: "nested property with slash notation",
|
||||
path: "user/settings/language",
|
||||
value: "en",
|
||||
expected: { user: { settings: { language: "en" } } },
|
||||
},
|
||||
{
|
||||
name: "mixed dot and slash notation",
|
||||
path: "user.settings/preferences.color",
|
||||
value: "blue",
|
||||
expected: { user: { settings: { preferences: { color: "blue" } } } },
|
||||
},
|
||||
{
|
||||
name: "overwrite primitive with object",
|
||||
path: "user.details",
|
||||
value: { age: 30 },
|
||||
expected: { user: { details: { age: 30 } } },
|
||||
},
|
||||
];
|
||||
|
||||
describe("setNestedProperty", () => {
|
||||
for (const { name, path, value, expected } of testCases) {
|
||||
test(name, () => {
|
||||
const obj = {};
|
||||
setNestedProperty(obj, path, value);
|
||||
expect(obj).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
test("throws for null object", () => {
|
||||
expect(() => {
|
||||
setNestedProperty(null, "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("throws for undefined object", () => {
|
||||
expect(() => {
|
||||
setNestedProperty(undefined, "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("throws for non-object target", () => {
|
||||
expect(() => {
|
||||
setNestedProperty("string", "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("throws for empty path", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "", "value");
|
||||
}).toThrow("Path must be a non-empty string");
|
||||
});
|
||||
|
||||
test("throws for __proto__ access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "__proto__.malicious", "attack");
|
||||
}).toThrow("Invalid property name: __proto__");
|
||||
});
|
||||
|
||||
test("throws for constructor access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "constructor.prototype.malicious", "attack");
|
||||
}).toThrow("Invalid property name: constructor");
|
||||
});
|
||||
|
||||
test("throws for prototype access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "obj.prototype.malicious", "attack");
|
||||
}).toThrow("Invalid property name: prototype");
|
||||
});
|
||||
|
||||
test("prevents prototype pollution", () => {
|
||||
const obj = {};
|
||||
|
||||
expect(() => {
|
||||
setNestedProperty(obj, "__proto__.polluted", true);
|
||||
}).toThrow("Invalid property name: __proto__");
|
||||
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.parallelB });
|
||||
|
||||
test("api keys happy path: user can create, copy, and revoke an API key", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
const keyName = `E2E CLI Key ${randomUUID().slice(0, 8)}`;
|
||||
|
||||
await page.goto("/profile/api-keys");
|
||||
await expect(page).toHaveURL(/\/profile\/api-keys/);
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Manage your AutoGPT Platform API keys for programmatic access",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Create Key" }).click();
|
||||
await page.getByLabel("Name").fill(keyName);
|
||||
const executeGraphCheckbox = page.getByRole("checkbox", {
|
||||
name: /EXECUTE_GRAPH/i,
|
||||
});
|
||||
const executeGraphChecked =
|
||||
(await executeGraphCheckbox.getAttribute("aria-checked")) === "true";
|
||||
if (!executeGraphChecked) {
|
||||
await executeGraphCheckbox.click();
|
||||
}
|
||||
await expect(executeGraphCheckbox).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
const secretDialog = page.getByRole("dialog", {
|
||||
name: "AutoGPT Platform API Key Created",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await secretDialog.isVisible().catch(() => false)) {
|
||||
return "created";
|
||||
}
|
||||
|
||||
const creationFailed = await page
|
||||
.getByText("Failed to create AutoGPT Platform API key")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (creationFailed) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
},
|
||||
{
|
||||
timeout: 30000,
|
||||
message:
|
||||
"API key creation should either open the created-key dialog or surface an explicit failure toast",
|
||||
},
|
||||
)
|
||||
.toBe("created");
|
||||
await expect(secretDialog).toBeVisible();
|
||||
|
||||
const createdSecret = (
|
||||
(await secretDialog.locator("code").textContent()) ?? ""
|
||||
).trim();
|
||||
expect(createdSecret.length).toBeGreaterThan(0);
|
||||
|
||||
await secretDialog.getByRole("button").first().click();
|
||||
await expect(page.getByText("Copied", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()), {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toBe(createdSecret);
|
||||
|
||||
await secretDialog.getByRole("button", { name: "Close" }).first().click();
|
||||
|
||||
const createdKeyRow = page
|
||||
.getByTestId("api-key-row")
|
||||
.filter({ hasText: keyName })
|
||||
.first();
|
||||
await expect(createdKeyRow).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await createdKeyRow.getByTestId("api-key-actions").click();
|
||||
await page.getByRole("menuitem", { name: "Revoke" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("AutoGPT Platform API key revoked successfully"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page.getByTestId("api-key-row").filter({ hasText: keyName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
158
autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts
Normal file
158
autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { getSeededTestUser } from "./credentials/accounts";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import {
|
||||
completeOnboardingWizard,
|
||||
skipOnboardingIfPresent,
|
||||
} from "./utils/onboarding";
|
||||
import { signupTestUser } from "./utils/signup";
|
||||
|
||||
test("auth happy path: user can sign up with a fresh account", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
});
|
||||
|
||||
test("auth happy path: user can sign up, enter the app, and log out", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
|
||||
await skipOnboardingIfPresent(page, "/marketplace");
|
||||
await expect(page).toHaveURL(/\/marketplace/);
|
||||
await expect(page.getByTestId("profile-popout-menu-trigger")).toBeVisible();
|
||||
|
||||
await page.getByTestId("profile-popout-menu-trigger").click();
|
||||
await page.getByRole("button", { name: "Log out" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
await page.goto("/library");
|
||||
await expect(page).toHaveURL(/\/login\?next=%2Flibrary/);
|
||||
});
|
||||
|
||||
test("auth happy path: seeded user can log in", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const testUser = getSeededTestUser("smokeAuth");
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
|
||||
await expect(page).toHaveURL(/\/marketplace/);
|
||||
await expect(page.getByTestId("profile-popout-menu-trigger")).toBeVisible();
|
||||
});
|
||||
|
||||
test("auth happy path: seeded user can log out and protected routes redirect to login", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const testUser = getSeededTestUser("primary");
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
|
||||
await expect(page).toHaveURL(/\/marketplace/);
|
||||
await page.getByTestId("profile-popout-menu-trigger").click();
|
||||
await page.getByRole("button", { name: "Log out" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
|
||||
|
||||
await page.goto("/profile");
|
||||
await expect(page).toHaveURL(/\/login\?next=%2Fprofile/);
|
||||
});
|
||||
|
||||
test("auth happy path: user can complete onboarding and land in the app", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
await completeOnboardingWizard(page, {
|
||||
name: "Smoke User",
|
||||
role: "Engineering",
|
||||
painPoints: ["Research", "Reports & data"],
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/copilot/);
|
||||
await expect(page.getByTestId("profile-popout-menu-trigger")).toBeVisible();
|
||||
});
|
||||
|
||||
test("auth happy path: multi-tab logout clears shared builder sessions", async ({
|
||||
context,
|
||||
}) => {
|
||||
// Two pages + builder load + logout sequence justifies a higher timeout
|
||||
test.setTimeout(90000);
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
const page1 = await context.newPage();
|
||||
const page2 = await context.newPage();
|
||||
const buildPage = new BuildPage(page1);
|
||||
|
||||
const recordWebSocketErrors =
|
||||
(label: string) => (msg: { type: () => string; text: () => string }) => {
|
||||
if (msg.type() === "error" && msg.text().includes("WebSocket")) {
|
||||
consoleErrors.push(`${label}: ${msg.text()}`);
|
||||
}
|
||||
};
|
||||
|
||||
page1.on("console", recordWebSocketErrors("page1"));
|
||||
page2.on("console", recordWebSocketErrors("page2"));
|
||||
|
||||
await signupTestUser(page1, undefined, undefined, false);
|
||||
await expect(page1).toHaveURL(/\/onboarding/);
|
||||
await skipOnboardingIfPresent(page1, "/build");
|
||||
|
||||
await page1.goto("/build");
|
||||
await expect(page1).toHaveURL(/\/build/);
|
||||
await buildPage.closeTutorial();
|
||||
await expect(page1.getByTestId("profile-popout-menu-trigger")).toBeVisible();
|
||||
|
||||
await page2.goto("/build");
|
||||
await expect(page2).toHaveURL(/\/build/);
|
||||
await expect(page2.getByTestId("profile-popout-menu-trigger")).toBeVisible();
|
||||
|
||||
await page1.getByTestId("profile-popout-menu-trigger").click();
|
||||
await page1.getByRole("button", { name: "Log out" }).click();
|
||||
await expect(page1).toHaveURL(/\/login/);
|
||||
|
||||
await page2.reload();
|
||||
await expect(page2).toHaveURL(/\/login\?next=%2Fbuild/);
|
||||
await expect(page2.getByTestId("profile-popout-menu-trigger")).toBeHidden();
|
||||
|
||||
expect(consoleErrors).toHaveLength(0);
|
||||
|
||||
// Prove the auth token is actually gone, not just the UI hidden. Supabase
|
||||
// overwrites the cookie on signout with an empty value + past expiry
|
||||
// rather than deleting it. An assertion that is silently skipped when the
|
||||
// cookie is missing under the expected name would hide a real regression,
|
||||
// so we assert on every non-empty sb-*auth-token* cookie explicitly.
|
||||
const cookiesAfterLogout = await context.cookies();
|
||||
const authCookies = cookiesAfterLogout.filter(
|
||||
(c) => c.name.startsWith("sb-") && c.name.includes("auth-token"),
|
||||
);
|
||||
for (const cookie of authCookies) {
|
||||
expect(
|
||||
cookie.value,
|
||||
`supabase auth cookie ${cookie.name} must be empty after logout`,
|
||||
).toBe("");
|
||||
}
|
||||
|
||||
await page1.close();
|
||||
await page2.close();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.builder });
|
||||
|
||||
test("builder happy path: user can walk through the builder tutorial and cancel midway, persisting canceled state", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
await buildPage.startTutorial();
|
||||
await buildPage.walkWelcomeToBlockMenu();
|
||||
await buildPage.walkSearchAndAddCalculator();
|
||||
await buildPage.cancelTutorial();
|
||||
|
||||
expect(await buildPage.getTutorialStateFromStorage()).toBe("canceled");
|
||||
expect(await buildPage.getNodeCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("builder happy path: user can skip the builder tutorial from the welcome step", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
await buildPage.startTutorial();
|
||||
await buildPage.skipTutorialFromWelcome();
|
||||
});
|
||||
|
||||
test("builder happy path: user can create a simple agent in builder with core blocks", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
await buildPage.open();
|
||||
await buildPage.addSimpleAgentBlocks();
|
||||
|
||||
await expect(buildPage.getNodeLocator()).toHaveCount(2);
|
||||
await expect(
|
||||
buildPage
|
||||
.getNodeLocator(0)
|
||||
.locator('input[placeholder="Enter string value..."]'),
|
||||
).toHaveValue("smoke-value");
|
||||
await expect(buildPage.getNodeTextInput("Add to Dictionary", 0)).toHaveValue(
|
||||
"smoke-key",
|
||||
);
|
||||
await expect(buildPage.getNodeTextInput("Add to Dictionary", 1)).toHaveValue(
|
||||
"smoke-value",
|
||||
);
|
||||
});
|
||||
|
||||
test("builder happy path: user can save the created agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
await buildPage.createAndSaveSimpleAgent("Smoke Save Agent");
|
||||
|
||||
await expect(page).toHaveURL(/flowID=/);
|
||||
expect(await buildPage.isRunButtonEnabled()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("builder happy path: user can run the saved agent from builder and see execution state", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
await buildPage.createAndSaveSimpleAgent("Smoke Run Agent");
|
||||
|
||||
await buildPage.startRun();
|
||||
await expect(
|
||||
page.locator('[data-id="stop-graph-button"], [data-id="run-graph-button"]'),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await expect
|
||||
.poll(() => buildPage.getExecutionState(), { timeout: 15000 })
|
||||
.not.toBe("unknown");
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
import { CopilotPage } from "./pages/copilot.page";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.marketplace });
|
||||
|
||||
test("copilot happy path: user can create a deterministic AutoPilot session and keep it after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const copilotPage = new CopilotPage(page);
|
||||
await copilotPage.open();
|
||||
|
||||
const sessionId = await copilotPage.createSessionViaApi();
|
||||
|
||||
await copilotPage.open(sessionId);
|
||||
await copilotPage.waitForChatInput();
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await copilotPage.dismissNotificationPrompt();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("sessionId"), {
|
||||
timeout: 15000,
|
||||
})
|
||||
.toBe(sessionId);
|
||||
await copilotPage.waitForChatInput();
|
||||
|
||||
// Sending a message must render the user's prompt in the conversation
|
||||
// immediately. This catches a regression where the chat input accepts
|
||||
// text but Enter is a no-op, without depending on knowing the exact
|
||||
// backend endpoint name (which has shifted historically).
|
||||
const userPrompt = `ping from e2e ${Date.now().toString().slice(-6)}`;
|
||||
const chatInput = copilotPage.getChatInput();
|
||||
await chatInput.fill(userPrompt);
|
||||
await chatInput.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByText(userPrompt, { exact: false }).first(),
|
||||
"user's typed prompt must appear in the chat after pressing Enter",
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from "path";
|
||||
|
||||
export const SEEDED_TEST_PASSWORD =
|
||||
process.env.SEEDED_TEST_PASSWORD || "testpassword123";
|
||||
export const SEEDED_USER_POOL_VERSION = "2.0.0";
|
||||
|
||||
export const SEEDED_TEST_ACCOUNTS = {
|
||||
primary: {
|
||||
key: "primary",
|
||||
email: "test123@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
smokeAuth: {
|
||||
key: "smokeAuth",
|
||||
email: "e2e.qa.auth@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
smokeBuilder: {
|
||||
key: "smokeBuilder",
|
||||
email: "e2e.qa.builder@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
smokeLibrary: {
|
||||
key: "smokeLibrary",
|
||||
email: "e2e.qa.library@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
smokeMarketplace: {
|
||||
key: "smokeMarketplace",
|
||||
email: "e2e.qa.marketplace@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
smokeSettings: {
|
||||
key: "smokeSettings",
|
||||
email: "e2e.qa.settings@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
parallelA: {
|
||||
key: "parallelA",
|
||||
email: "e2e.qa.parallel.a@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
parallelB: {
|
||||
key: "parallelB",
|
||||
email: "e2e.qa.parallel.b@example.com",
|
||||
password: SEEDED_TEST_PASSWORD,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SeededTestAccountKey = keyof typeof SEEDED_TEST_ACCOUNTS;
|
||||
export type SeededTestAccount =
|
||||
(typeof SEEDED_TEST_ACCOUNTS)[SeededTestAccountKey];
|
||||
|
||||
export const SEEDED_TEST_USERS = Object.values(SEEDED_TEST_ACCOUNTS);
|
||||
export const SEEDED_AUTH_STATE_ACCOUNT_KEYS = [
|
||||
"smokeBuilder",
|
||||
"smokeLibrary",
|
||||
"smokeMarketplace",
|
||||
"smokeSettings",
|
||||
"parallelA",
|
||||
"parallelB",
|
||||
] as const;
|
||||
|
||||
export const AUTH_DIRECTORY = path.resolve(process.cwd(), ".auth");
|
||||
|
||||
export function getAuthStatePath(accountKey: SeededTestAccountKey) {
|
||||
return path.join(AUTH_DIRECTORY, "states", `${accountKey}.json`);
|
||||
}
|
||||
|
||||
export const E2E_AUTH_STATES = {
|
||||
builder: getAuthStatePath("smokeBuilder"),
|
||||
library: getAuthStatePath("smokeLibrary"),
|
||||
marketplace: getAuthStatePath("smokeMarketplace"),
|
||||
settings: getAuthStatePath("smokeSettings"),
|
||||
parallelA: getAuthStatePath("parallelA"),
|
||||
parallelB: getAuthStatePath("parallelB"),
|
||||
} as const;
|
||||
|
||||
export const SMOKE_AUTH_STATES = E2E_AUTH_STATES;
|
||||
|
||||
export function getSeededTestUser(
|
||||
accountKey: SeededTestAccountKey = "primary",
|
||||
): SeededTestAccount {
|
||||
return SEEDED_TEST_ACCOUNTS[accountKey];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getSeededTestUser } from "./accounts";
|
||||
|
||||
// E2E Test Credentials and Constants
|
||||
export const TEST_CREDENTIALS = getSeededTestUser("primary");
|
||||
|
||||
export function getTestUserWithLibraryAgents() {
|
||||
return TEST_CREDENTIALS;
|
||||
}
|
||||
|
||||
// Dummy constant to help developers identify agents that don't need input
|
||||
export const DummyInput = "DummyInput";
|
||||
|
||||
// This will be used for testing agent submission for test123@example.com
|
||||
export const TEST_AGENT_DATA = {
|
||||
name: "E2E Calculator Agent",
|
||||
description:
|
||||
"A deterministic marketplace agent built from Calculator and Agent Output blocks for frontend E2E coverage.",
|
||||
image_urls: [
|
||||
"https://picsum.photos/seed/e2e-marketplace-1/200/300",
|
||||
"https://picsum.photos/seed/e2e-marketplace-2/200/301",
|
||||
"https://picsum.photos/seed/e2e-marketplace-3/200/302",
|
||||
],
|
||||
video_url: "https://www.youtube.com/watch?v=test123",
|
||||
sub_heading: "A deterministic calculator agent for PR E2E coverage",
|
||||
categories: ["test", "demo", "frontend"],
|
||||
changes_summary: "Initial deterministic calculator submission",
|
||||
} as const;
|
||||
@@ -0,0 +1,23 @@
|
||||
export function buildCookieConsentStorageState(
|
||||
origin: string = "http://localhost:3000",
|
||||
) {
|
||||
return {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin,
|
||||
localStorage: [
|
||||
{
|
||||
name: "autogpt_cookie_consent",
|
||||
value: JSON.stringify({
|
||||
hasConsented: true,
|
||||
timestamp: Date.now(),
|
||||
analytics: true,
|
||||
monitoring: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
49
autogpt_platform/frontend/src/playwright/global-setup.ts
Normal file
49
autogpt_platform/frontend/src/playwright/global-setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { FullConfig } from "@playwright/test";
|
||||
import {
|
||||
ensureSeededAuthStates,
|
||||
getInvalidSeededAuthStateKeys,
|
||||
} from "./utils/auth";
|
||||
|
||||
function resolveBaseURL(config: FullConfig) {
|
||||
const configuredBaseURL =
|
||||
config.projects[0]?.use?.baseURL ?? "http://localhost:3000";
|
||||
|
||||
if (typeof configuredBaseURL !== "string") {
|
||||
throw new Error(
|
||||
`Playwright baseURL must be a string during global setup. Received ${String(
|
||||
configuredBaseURL,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return configuredBaseURL;
|
||||
}
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log("🚀 Starting global test setup...");
|
||||
|
||||
try {
|
||||
const baseURL = resolveBaseURL(config);
|
||||
const invalidKeys = await getInvalidSeededAuthStateKeys(baseURL);
|
||||
|
||||
if (invalidKeys.length === 0) {
|
||||
console.log("♻️ Reusing stored seeded auth states");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔐 Refreshing seeded auth states for: ${invalidKeys.join(", ")}`,
|
||||
);
|
||||
await ensureSeededAuthStates(baseURL);
|
||||
|
||||
console.log("✅ Global setup completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("❌ Global setup failed:", error);
|
||||
console.error(
|
||||
"💡 Run backend/test/e2e_test_data.py to seed the deterministic Playwright accounts before retrying.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,559 @@
|
||||
import path from "path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
import { BuildPage, createUniqueAgentName } from "./pages/build.page";
|
||||
import {
|
||||
clickRunButton,
|
||||
dismissFeedbackDialog,
|
||||
getActiveItemId,
|
||||
importAgentFromFile,
|
||||
LibraryPage,
|
||||
} from "./pages/library.page";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.library });
|
||||
|
||||
const TEST_AGENT_PATH = path.resolve(__dirname, "assets", "testing_agent.json");
|
||||
const CALCULATOR_BLOCK_ID = "b1ab9b19-67a6-406d-abf5-2dba76d00c79";
|
||||
const AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4";
|
||||
const STOPPED_RUN_STATUSES = new Set([
|
||||
"terminated",
|
||||
"failed",
|
||||
"incomplete",
|
||||
"completed",
|
||||
]);
|
||||
|
||||
type UploadedGraphNode = {
|
||||
id: string;
|
||||
block_id: string;
|
||||
input_default: Record<string, unknown>;
|
||||
metadata: {
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
input_links: unknown[];
|
||||
output_links: unknown[];
|
||||
};
|
||||
|
||||
function createLongRunningCalculatorGraph(
|
||||
agentName: string,
|
||||
calculatorCount: number = 150,
|
||||
) {
|
||||
const nodes: UploadedGraphNode[] = Array.from(
|
||||
{ length: calculatorCount },
|
||||
(_, index) => ({
|
||||
id: `calc-${index + 1}`,
|
||||
block_id: CALCULATOR_BLOCK_ID,
|
||||
input_default:
|
||||
index === 0
|
||||
? {
|
||||
operation: "Add",
|
||||
a: 1,
|
||||
b: 1,
|
||||
round_result: false,
|
||||
}
|
||||
: {
|
||||
operation: "Add",
|
||||
b: 1,
|
||||
round_result: false,
|
||||
},
|
||||
metadata: {
|
||||
position: { x: 320 * index, y: 120 },
|
||||
},
|
||||
input_links: [],
|
||||
output_links: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const links = Array.from({ length: calculatorCount - 1 }, (_, index) => ({
|
||||
source_id: `calc-${index + 1}`,
|
||||
sink_id: `calc-${index + 2}`,
|
||||
source_name: "result",
|
||||
sink_name: "a",
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
id: "final-output",
|
||||
block_id: AGENT_OUTPUT_BLOCK_ID,
|
||||
input_default: {
|
||||
name: "Final result",
|
||||
description: "Long-running calculator chain output",
|
||||
},
|
||||
metadata: {
|
||||
position: { x: 320 * calculatorCount, y: 120 },
|
||||
},
|
||||
input_links: [],
|
||||
output_links: [],
|
||||
});
|
||||
links.push({
|
||||
source_id: `calc-${calculatorCount}`,
|
||||
sink_id: "final-output",
|
||||
source_name: "result",
|
||||
sink_name: "value",
|
||||
});
|
||||
|
||||
return {
|
||||
name: agentName,
|
||||
description:
|
||||
"Deterministic long-running calculator chain for runner stop coverage",
|
||||
is_active: true,
|
||||
nodes,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
async function createLongRunningSavedAgent(
|
||||
page: Page,
|
||||
agentName: string,
|
||||
): Promise<{ graphId: string; graphVersion: number }> {
|
||||
const response = await page.request.post("/api/proxy/api/graphs", {
|
||||
data: {
|
||||
graph: createLongRunningCalculatorGraph(agentName),
|
||||
source: "upload",
|
||||
},
|
||||
});
|
||||
expect(response.ok(), "expected graph creation API request to succeed").toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
id?: string;
|
||||
version?: number;
|
||||
data?: { id?: string; version?: number };
|
||||
};
|
||||
expect(
|
||||
body.data?.id ?? body.id,
|
||||
"graph creation should return a graph id",
|
||||
).toBeTruthy();
|
||||
|
||||
return {
|
||||
graphId: String(body.data?.id ?? body.id),
|
||||
graphVersion: Number(body.data?.version ?? body.version ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDeterministicCalculatorSavedAgent(
|
||||
page: Page,
|
||||
agentName: string,
|
||||
outputName: string,
|
||||
): Promise<void> {
|
||||
const response = await page.request.post("/api/proxy/api/graphs", {
|
||||
data: {
|
||||
graph: {
|
||||
name: agentName,
|
||||
description:
|
||||
"Deterministic calculator output for run-result assertions",
|
||||
is_active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: "calc-1",
|
||||
block_id: CALCULATOR_BLOCK_ID,
|
||||
input_default: {
|
||||
operation: "Add",
|
||||
a: 1,
|
||||
b: 1,
|
||||
round_result: false,
|
||||
},
|
||||
metadata: {
|
||||
position: { x: 120, y: 160 },
|
||||
},
|
||||
input_links: [],
|
||||
output_links: [],
|
||||
},
|
||||
{
|
||||
id: "final-output",
|
||||
block_id: AGENT_OUTPUT_BLOCK_ID,
|
||||
input_default: {
|
||||
name: outputName,
|
||||
description: "Deterministic result output",
|
||||
},
|
||||
metadata: {
|
||||
position: { x: 520, y: 160 },
|
||||
},
|
||||
input_links: [],
|
||||
output_links: [],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
source_id: "calc-1",
|
||||
sink_id: "final-output",
|
||||
source_name: "result",
|
||||
sink_name: "value",
|
||||
},
|
||||
],
|
||||
},
|
||||
source: "upload",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
"expected deterministic calculator graph creation API request to succeed",
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function getExecutionStatusFromApi(
|
||||
page: Page,
|
||||
graphId: string,
|
||||
runId: string,
|
||||
): Promise<string> {
|
||||
const response = await page.request.get(
|
||||
`/api/proxy/api/graphs/${graphId}/executions/${runId}`,
|
||||
);
|
||||
expect(response.ok(), "execution details API should succeed").toBe(true);
|
||||
|
||||
const body = (await response.json()) as { status?: string };
|
||||
return body.status?.toLowerCase() ?? "unknown";
|
||||
}
|
||||
|
||||
async function createAndSaveDeterministicOutputAgent(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
): Promise<{ agentName: string; expectedOutput: string; outputName: string }> {
|
||||
const buildPage = new BuildPage(page);
|
||||
const agentName = createUniqueAgentName(prefix);
|
||||
const expectedOutput = `e2e-output-${Date.now()}`;
|
||||
const outputName = `e2e-result-${Date.now()}`;
|
||||
|
||||
await buildPage.open();
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
await buildPage.fillBlockInputByPlaceholder(
|
||||
"Enter string value...",
|
||||
expectedOutput,
|
||||
0,
|
||||
);
|
||||
|
||||
await buildPage.addBlockByClick("Agent Output");
|
||||
await buildPage.waitForNodeOnCanvas(2);
|
||||
await buildPage.connectNodes(0, 1);
|
||||
await buildPage.fillLastNodeTextInput("Agent Output", outputName);
|
||||
|
||||
await buildPage.saveAgent(
|
||||
agentName,
|
||||
"Deterministic output agent for library run verification",
|
||||
);
|
||||
await buildPage.waitForSaveComplete();
|
||||
await buildPage.waitForSaveButton();
|
||||
|
||||
return { agentName, expectedOutput, outputName };
|
||||
}
|
||||
|
||||
test("library happy path: user can import an agent file into Library", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const { importedAgent } = await importAgentFromFile(
|
||||
page,
|
||||
TEST_AGENT_PATH,
|
||||
createUniqueAgentName("E2E Import Agent"),
|
||||
);
|
||||
|
||||
expect(importedAgent.name).toContain("E2E Import Agent");
|
||||
});
|
||||
|
||||
test("library happy path: user can open the imported or saved agent from Library in builder", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const { libraryPage, importedAgent } = await importAgentFromFile(
|
||||
page,
|
||||
TEST_AGENT_PATH,
|
||||
createUniqueAgentName("E2E Open Agent"),
|
||||
);
|
||||
|
||||
// Register the popup listener before clicking so we don't miss a fast open.
|
||||
// A short timeout covers the case where the link opens in the current tab.
|
||||
const popupPromise = page
|
||||
.context()
|
||||
.waitForEvent("page", { timeout: 10000 })
|
||||
.catch(() => null);
|
||||
await libraryPage.clickOpenInBuilder(importedAgent);
|
||||
const builderPage = (await popupPromise) ?? page;
|
||||
|
||||
await builderPage.waitForLoadState("domcontentloaded");
|
||||
await expect(builderPage).toHaveURL(/\/build/);
|
||||
const importedBuildPage = new BuildPage(builderPage);
|
||||
await importedBuildPage.waitForNodeOnCanvas();
|
||||
expect(await importedBuildPage.getNodeCount()).toBeGreaterThan(0);
|
||||
if (builderPage !== page) {
|
||||
await builderPage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("library happy path: user can start and stop a saved task from runner UI", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
const agentName = createUniqueAgentName("E2E Stop Task Agent");
|
||||
const { graphId } = await createLongRunningSavedAgent(page, agentName);
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveItemId(page), { timeout: 45000 })
|
||||
.not.toBe(null);
|
||||
const runId = getActiveItemId(page);
|
||||
expect(runId, "run id should be present after starting task").toBeTruthy();
|
||||
await expect
|
||||
.poll(() => libraryPage.getRunStatus(), { timeout: 45000 })
|
||||
.toBe("running");
|
||||
|
||||
const stopTaskButton = page.getByRole("button", { name: /Stop task/i });
|
||||
await expect(stopTaskButton).toBeVisible({ timeout: 30000 });
|
||||
const stopResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
response
|
||||
.url()
|
||||
.includes(`/api/graphs/${graphId}/executions/${runId}/stop`),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await stopTaskButton.click();
|
||||
const stopResponse = await stopResponsePromise;
|
||||
|
||||
expect(stopResponse.ok(), "stop run API should succeed").toBe(true);
|
||||
await expect(page.getByText("Run stopped")).toBeVisible({ timeout: 15000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const status = await getExecutionStatusFromApi(
|
||||
page,
|
||||
graphId,
|
||||
String(runId),
|
||||
);
|
||||
return STOPPED_RUN_STATUSES.has(status) ? status : "running";
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
)
|
||||
.not.toBe("running");
|
||||
});
|
||||
|
||||
test("library happy path: user can run a saved agent and verify expected output", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150000);
|
||||
|
||||
const agentName = createUniqueAgentName("E2E Expected Output Agent");
|
||||
const outputName = `e2e-result-${Date.now()}`;
|
||||
await createDeterministicCalculatorSavedAgent(page, agentName, outputName);
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
await libraryPage.waitForRunToComplete();
|
||||
await dismissFeedbackDialog(page);
|
||||
|
||||
await libraryPage.assertRunProducedOutput();
|
||||
await libraryPage.assertRunOutputValue(outputName, /^2(?:\.0+)?$/);
|
||||
await expect
|
||||
.poll(() => libraryPage.getRunStatus(), { timeout: 15000 })
|
||||
.toBe("completed");
|
||||
});
|
||||
|
||||
test("library happy path: user can edit a saved agent from Library and keep changes after refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150000);
|
||||
|
||||
const { agentName } = await createAndSaveDeterministicOutputAgent(
|
||||
page,
|
||||
"E2E Edit Persist Agent",
|
||||
);
|
||||
const editedValue = `edited-value-${Date.now()}`;
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await page.goto("/library");
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
await libraryPage.searchAgents(agentName);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const agentCard = page
|
||||
.getByTestId("library-agent-card")
|
||||
.filter({ hasText: agentName })
|
||||
.first();
|
||||
await expect(agentCard).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const popupPromise = page
|
||||
.context()
|
||||
.waitForEvent("page", { timeout: 10000 })
|
||||
.catch(() => null);
|
||||
await agentCard
|
||||
.getByTestId("library-agent-card-open-in-builder-link")
|
||||
.first()
|
||||
.click();
|
||||
const builderPage = (await popupPromise) ?? page;
|
||||
|
||||
const builderTabPage = new BuildPage(builderPage);
|
||||
await builderTabPage.waitForNodeOnCanvas();
|
||||
await builderTabPage.fillBlockInputByPlaceholder(
|
||||
"Enter string value...",
|
||||
editedValue,
|
||||
0,
|
||||
);
|
||||
|
||||
await builderPage.getByTestId("save-control-save-button").click();
|
||||
const saveAgentButton = builderPage.getByRole("button", {
|
||||
name: "Save Agent",
|
||||
});
|
||||
if (await saveAgentButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await expect(saveAgentButton).toBeEnabled({ timeout: 10000 });
|
||||
await saveAgentButton.click();
|
||||
await expect(saveAgentButton).toBeHidden({ timeout: 15000 });
|
||||
}
|
||||
|
||||
await builderPage.reload();
|
||||
await builderTabPage.waitForNodeOnCanvas();
|
||||
await expect(
|
||||
builderTabPage
|
||||
.getNodeLocator(0)
|
||||
.locator('input[placeholder="Enter string value..."]'),
|
||||
).toHaveValue(editedValue);
|
||||
|
||||
if (builderPage !== page) {
|
||||
await builderPage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("library happy path: user can rerun a completed task from the Library agent page", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
const { agentName } =
|
||||
await buildPage.createAndSaveSimpleAgent("E2E Rerun Agent");
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
await libraryPage.waitForRunToComplete();
|
||||
await dismissFeedbackDialog(page);
|
||||
|
||||
const rerunTaskButton = page.getByRole("button", { name: /Rerun task/i });
|
||||
await expect(rerunTaskButton).toBeVisible({ timeout: 45000 });
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveItemId(page), { timeout: 45000 })
|
||||
.not.toBe(null);
|
||||
|
||||
const initialRunId = getActiveItemId(page);
|
||||
expect(initialRunId).toBeTruthy();
|
||||
|
||||
await rerunTaskButton.click();
|
||||
|
||||
await expect(page.getByText("Run started", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveItemId(page), { timeout: 45000 })
|
||||
.not.toBe(initialRunId);
|
||||
|
||||
await libraryPage.waitForRunToComplete();
|
||||
|
||||
// Simple agent has no AgentOutputBlock — verify run completion only.
|
||||
const runStatus = await libraryPage.getRunStatus();
|
||||
expect(runStatus).toBe("completed");
|
||||
});
|
||||
|
||||
test("library happy path: user can delete a completed task from the run sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
const { agentName } = await buildPage.createAndSaveSimpleAgent(
|
||||
"E2E Delete Task Agent",
|
||||
);
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
await libraryPage.waitForRunToComplete();
|
||||
await dismissFeedbackDialog(page);
|
||||
|
||||
// Open the per-task actions dropdown ("More actions" three-dot button)
|
||||
// and use the menu's Delete task option to remove the run.
|
||||
const moreActionsButton = page
|
||||
.getByRole("button", { name: "More actions" })
|
||||
.first();
|
||||
await expect(moreActionsButton).toBeVisible({ timeout: 15000 });
|
||||
await moreActionsButton.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: /Delete( this)? task/i }).click();
|
||||
|
||||
const confirmDialog = page.getByRole("dialog", { name: /Delete task/i });
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 10000 });
|
||||
await confirmDialog.getByRole("button", { name: /^Delete Task$/ }).click();
|
||||
|
||||
// Toast confirms the backend actually deleted (not just dialog closed).
|
||||
await expect(page.getByText("Task deleted", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Sidebar should drop the only run, returning the page to initial
|
||||
// task-entry state.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^(Setup your task|New task)$/i }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("library happy path: user can open the agent in builder from the exact runner customise-agent path", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
const { agentName } = await buildPage.createAndSaveSimpleAgent(
|
||||
"E2E View Task Agent",
|
||||
);
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
await libraryPage.waitForRunToComplete();
|
||||
await dismissFeedbackDialog(page);
|
||||
|
||||
// The "View task details" eye-icon button on a completed run opens the
|
||||
// agent in the builder in a new tab. This exercises the runner → builder
|
||||
// navigation that QA item #22 ("Customise Agent" from Runner UI) covers.
|
||||
const selectedRunId = getActiveItemId(page);
|
||||
expect(selectedRunId).toBeTruthy();
|
||||
|
||||
const viewTaskButton = page
|
||||
.locator('[aria-label="View task details"]')
|
||||
.first();
|
||||
await expect(viewTaskButton).toBeVisible({ timeout: 15000 });
|
||||
const customiseAgentHref = await viewTaskButton.getAttribute("href");
|
||||
expect(customiseAgentHref).toContain("flowID=");
|
||||
expect(customiseAgentHref).toContain("flowVersion=");
|
||||
expect(customiseAgentHref).toContain(`flowExecutionID=${selectedRunId}`);
|
||||
|
||||
const popupPromise = context.waitForEvent("page", { timeout: 15000 });
|
||||
await viewTaskButton.click();
|
||||
const builderTab = await popupPromise;
|
||||
|
||||
await builderTab.waitForLoadState("domcontentloaded");
|
||||
await expect(builderTab).toHaveURL(/\/build/);
|
||||
await expect(builderTab).toHaveURL(
|
||||
new RegExp(`flowExecutionID=${selectedRunId}`),
|
||||
);
|
||||
|
||||
// Verify the builder canvas actually rendered with the agent's nodes —
|
||||
// a navigation that lands on /build but never paints the graph would
|
||||
// otherwise pass on URL alone.
|
||||
const builderTabPage = new BuildPage(builderTab);
|
||||
await builderTabPage.waitForNodeOnCanvas();
|
||||
expect(await builderTabPage.getNodeCount()).toBeGreaterThan(0);
|
||||
|
||||
await builderTab.close();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
import {
|
||||
clickRunButton,
|
||||
dismissFeedbackDialog,
|
||||
LibraryPage,
|
||||
} from "./pages/library.page";
|
||||
import { MarketplacePage } from "./pages/marketplace.page";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.marketplace });
|
||||
|
||||
test("marketplace happy path: user can browse Marketplace and open an agent detail page", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.openFeaturedAgent();
|
||||
|
||||
await expect(page.getByTestId("agent-description")).toBeVisible();
|
||||
});
|
||||
|
||||
test("marketplace happy path: user can add a Marketplace agent to Library and run it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.openRunnableAgent();
|
||||
|
||||
const agentName = await page.getByTestId("agent-title").innerText();
|
||||
|
||||
await page.getByTestId("agent-add-library-button").click();
|
||||
await expect(page.getByText("Redirecting to your library...")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/library\/agents\//);
|
||||
|
||||
const libraryPage = new LibraryPage(page);
|
||||
await libraryPage.openSavedAgent(agentName);
|
||||
await clickRunButton(page);
|
||||
|
||||
await libraryPage.waitForRunToComplete();
|
||||
await dismissFeedbackDialog(page);
|
||||
|
||||
const runStatus = await libraryPage.getRunStatus();
|
||||
expect(runStatus).toBe("completed");
|
||||
await libraryPage.assertRunProducedOutput();
|
||||
await libraryPage.assertFirstRunOutputValue(/^\d+(?:\.0+)?$/);
|
||||
});
|
||||
642
autogpt_platform/frontend/src/playwright/pages/build.page.ts
Normal file
642
autogpt_platform/frontend/src/playwright/pages/build.page.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export function createUniqueAgentName(prefix: string): string {
|
||||
return `${prefix} ${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export class BuildPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto("/build");
|
||||
await this.page.waitForLoadState("domcontentloaded");
|
||||
}
|
||||
|
||||
async isLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
|
||||
await this.page
|
||||
.locator(".react-flow")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async closeTutorial(): Promise<void> {
|
||||
try {
|
||||
await this.page
|
||||
.getByRole("button", { name: "Skip Tutorial", exact: true })
|
||||
.click({ timeout: 3000 });
|
||||
} catch {
|
||||
// Tutorial not shown or already dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block Menu ---
|
||||
|
||||
async openBlocksPanel(): Promise<void> {
|
||||
const popoverContent = this.page.locator(
|
||||
'[data-id="blocks-control-popover-content"]',
|
||||
);
|
||||
if (!(await popoverContent.isVisible())) {
|
||||
await this.page.getByTestId("blocks-control-blocks-button").click();
|
||||
await popoverContent.waitFor({ state: "visible", timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async closeBlocksPanel(): Promise<void> {
|
||||
const popoverContent = this.page.locator(
|
||||
'[data-id="blocks-control-popover-content"]',
|
||||
);
|
||||
if (await popoverContent.isVisible()) {
|
||||
await this.page.getByTestId("blocks-control-blocks-button").click();
|
||||
await popoverContent.waitFor({ state: "hidden", timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async searchBlock(searchTerm: string): Promise<void> {
|
||||
const searchInput = this.page.locator(
|
||||
'[data-id="blocks-control-search-bar"] input[type="text"]',
|
||||
);
|
||||
await searchInput.clear();
|
||||
await searchInput.fill(searchTerm);
|
||||
await expect(searchInput).toHaveValue(searchTerm);
|
||||
}
|
||||
|
||||
private getBlockCardByName(name: string): Locator {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const exactName = new RegExp(`^\\s*${escapedName}\\s*$`, "i");
|
||||
return this.page
|
||||
.locator('[data-id^="block-card-"]')
|
||||
.filter({ has: this.page.locator("span", { hasText: exactName }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
async addBlockByClick(searchTerm: string): Promise<void> {
|
||||
await this.openBlocksPanel();
|
||||
const blockCard = this.getBlockCardByName(searchTerm);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await this.searchBlock(searchTerm);
|
||||
|
||||
const cardVisible = await blockCard
|
||||
.waitFor({
|
||||
state: "visible",
|
||||
timeout: attempt === 0 ? 15000 : 5000,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(blockCard).toBeVisible({ timeout: 5000 });
|
||||
await blockCard.click();
|
||||
|
||||
// Close the panel so it doesn't overlay the canvas
|
||||
await this.closeBlocksPanel();
|
||||
}
|
||||
|
||||
async dragBlockToCanvas(searchTerm: string): Promise<void> {
|
||||
await this.openBlocksPanel();
|
||||
await this.searchBlock(searchTerm);
|
||||
|
||||
const anyCard = this.page.locator('[data-id^="block-card-"]').first();
|
||||
await anyCard.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
const blockCard = this.getBlockCardByName(searchTerm);
|
||||
await blockCard.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
const canvas = this.page.locator(".react-flow__pane").first();
|
||||
await blockCard.dragTo(canvas);
|
||||
}
|
||||
|
||||
// --- Nodes on Canvas ---
|
||||
|
||||
getNodeLocator(index?: number): Locator {
|
||||
const locator = this.page.locator('[data-id^="custom-node-"]');
|
||||
return index !== undefined ? locator.nth(index) : locator;
|
||||
}
|
||||
|
||||
getNodeLocatorByTitle(title: string): Locator {
|
||||
const exactTitle = new RegExp(`^\\s*${escapeRegex(title)}\\s*$`, "i");
|
||||
return this.page
|
||||
.locator('[data-id^="custom-node-"]')
|
||||
.filter({ has: this.page.getByText(exactTitle) })
|
||||
.first();
|
||||
}
|
||||
|
||||
getNodeTextInputs(nodeTitle: string): Locator {
|
||||
return this.getNodeLocatorByTitle(nodeTitle).locator(
|
||||
'input[placeholder="Enter string value..."]:visible',
|
||||
);
|
||||
}
|
||||
|
||||
getNodeTextInput(nodeTitle: string, inputIndex = 0): Locator {
|
||||
return this.getNodeTextInputs(nodeTitle).nth(inputIndex);
|
||||
}
|
||||
|
||||
async fillNodeTextInput(
|
||||
nodeTitle: string,
|
||||
value: string,
|
||||
inputIndex = 0,
|
||||
): Promise<void> {
|
||||
const node = this.getNodeLocatorByTitle(nodeTitle);
|
||||
await expect(node).toBeVisible({ timeout: 15000 });
|
||||
await expect
|
||||
.poll(async () => await this.getNodeTextInputs(nodeTitle).count(), {
|
||||
timeout: 15000,
|
||||
})
|
||||
.toBeGreaterThan(inputIndex);
|
||||
const input = this.getNodeTextInput(nodeTitle, inputIndex);
|
||||
await input.scrollIntoViewIfNeeded();
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
async fillLastNodeTextInput(nodeTitle: string, value: string): Promise<void> {
|
||||
const node = this.getNodeLocatorByTitle(nodeTitle);
|
||||
await expect(node).toBeVisible({ timeout: 15000 });
|
||||
await expect
|
||||
.poll(async () => await this.getNodeTextInputs(nodeTitle).count(), {
|
||||
timeout: 15000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
const input = this.getNodeTextInputs(nodeTitle).last();
|
||||
await input.scrollIntoViewIfNeeded();
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.getNodeLocator().count();
|
||||
}
|
||||
|
||||
async waitForNodeOnCanvas(expectedCount?: number): Promise<void> {
|
||||
if (expectedCount !== undefined) {
|
||||
await expect(this.getNodeLocator()).toHaveCount(expectedCount, {
|
||||
timeout: 10000,
|
||||
});
|
||||
} else {
|
||||
await this.getNodeLocator()
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
async selectNode(index: number = 0): Promise<void> {
|
||||
const node = this.getNodeLocator(index);
|
||||
await node.click();
|
||||
}
|
||||
|
||||
async selectAllNodes(): Promise<void> {
|
||||
await this.page.locator(".react-flow__pane").first().click();
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
}
|
||||
|
||||
async deleteSelectedNodes(): Promise<void> {
|
||||
await this.page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
// --- Connections (Edges) ---
|
||||
|
||||
async connectNodes(
|
||||
sourceNodeIndex: number,
|
||||
targetNodeIndex: number,
|
||||
): Promise<void> {
|
||||
// Get the node wrapper elements to scope handle search
|
||||
const sourceNode = this.getNodeLocator(sourceNodeIndex);
|
||||
const targetNode = this.getNodeLocator(targetNodeIndex);
|
||||
|
||||
// ReactFlow renders Handle components as .react-flow__handle elements
|
||||
// Output handles have class .react-flow__handle-right (Position.Right)
|
||||
// Input handles have class .react-flow__handle-left (Position.Left)
|
||||
const sourceHandle = sourceNode
|
||||
.locator(".react-flow__handle-right")
|
||||
.first();
|
||||
const targetHandle = targetNode.locator(".react-flow__handle-left").first();
|
||||
|
||||
// Get precise center coordinates using evaluate to avoid CSS transform issues
|
||||
const getHandleCenter = async (locator: Locator) => {
|
||||
const el = await locator.elementHandle();
|
||||
if (!el) throw new Error("Handle element not found");
|
||||
const rect = await el.evaluate((node) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
});
|
||||
return rect;
|
||||
};
|
||||
|
||||
const source = await getHandleCenter(sourceHandle);
|
||||
const target = await getHandleCenter(targetHandle);
|
||||
|
||||
// ReactFlow requires a proper drag sequence with intermediate moves
|
||||
await this.page.mouse.move(source.x, source.y);
|
||||
await this.page.mouse.down();
|
||||
// Move in steps to trigger ReactFlow's connection detection
|
||||
const steps = 20;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const ratio = i / steps;
|
||||
await this.page.mouse.move(
|
||||
source.x + (target.x - source.x) * ratio,
|
||||
source.y + (target.y - source.y) * ratio,
|
||||
);
|
||||
}
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
async getEdgeCount(): Promise<number> {
|
||||
return await this.page.locator(".react-flow__edge").count();
|
||||
}
|
||||
|
||||
// --- Save ---
|
||||
|
||||
async saveAgent(
|
||||
name: string = "Test Agent",
|
||||
description: string = "",
|
||||
): Promise<void> {
|
||||
await this.page.getByTestId("save-control-save-button").click();
|
||||
|
||||
const nameInput = this.page.getByTestId("save-control-name-input");
|
||||
await nameInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await nameInput.fill(name);
|
||||
|
||||
if (description) {
|
||||
await this.page
|
||||
.getByTestId("save-control-description-input")
|
||||
.fill(description);
|
||||
}
|
||||
|
||||
await this.page.getByTestId("save-control-save-agent-button").click();
|
||||
}
|
||||
|
||||
async waitForSaveComplete(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/flowID=/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async waitForSaveButton(): Promise<void> {
|
||||
await this.page.waitForSelector(
|
||||
'[data-testid="save-control-save-button"]:not([disabled])',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Run ---
|
||||
|
||||
async isRunButtonEnabled(): Promise<boolean> {
|
||||
const runButton = this.page.locator('[data-id="run-graph-button"]');
|
||||
return await runButton.isEnabled();
|
||||
}
|
||||
|
||||
async clickRunButton(): Promise<void> {
|
||||
// Dismiss any post-save toast that may be intercepting pointer events on
|
||||
// the run button. Actively close it rather than waiting for Sonner's
|
||||
// default auto-dismiss — the auto-dismiss + fade-out routinely runs over
|
||||
// 5s and caused flakes here. The toast is optional (only after save), so
|
||||
// the dismissal is guarded.
|
||||
await this.dismissSaveToast();
|
||||
const runButton = this.page.locator('[data-id="run-graph-button"]');
|
||||
await runButton.click();
|
||||
}
|
||||
|
||||
// --- Undo / Redo ---
|
||||
|
||||
async isUndoEnabled(): Promise<boolean> {
|
||||
const btn = this.page.locator('[data-id="undo-button"]');
|
||||
return !(await btn.isDisabled());
|
||||
}
|
||||
|
||||
async isRedoEnabled(): Promise<boolean> {
|
||||
const btn = this.page.locator('[data-id="redo-button"]');
|
||||
return !(await btn.isDisabled());
|
||||
}
|
||||
|
||||
async clickUndo(): Promise<void> {
|
||||
await this.page.locator('[data-id="undo-button"]').click();
|
||||
}
|
||||
|
||||
async clickRedo(): Promise<void> {
|
||||
await this.page.locator('[data-id="redo-button"]').click();
|
||||
}
|
||||
|
||||
// --- Copy / Paste ---
|
||||
|
||||
async copyViaKeyboard(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+c" : "Control+c");
|
||||
}
|
||||
|
||||
async pasteViaKeyboard(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+v" : "Control+v");
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async fillBlockInputByPlaceholder(
|
||||
placeholder: string,
|
||||
value: string,
|
||||
nodeIndex: number = 0,
|
||||
): Promise<void> {
|
||||
const node = this.getNodeLocator(nodeIndex);
|
||||
const input = node.getByPlaceholder(placeholder);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
async clickCanvas(): Promise<void> {
|
||||
const pane = this.page.locator(".react-flow__pane").first();
|
||||
const box = await pane.boundingBox();
|
||||
if (box) {
|
||||
// Click in the center of the canvas to avoid sidebar/toolbar overlaps
|
||||
await pane.click({
|
||||
position: { x: box.width / 2, y: box.height / 2 },
|
||||
});
|
||||
} else {
|
||||
await pane.click();
|
||||
}
|
||||
}
|
||||
|
||||
getPlaywrightPage(): Page {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
getSavedGraphRef(): { graphId: string; graphVersion: number } {
|
||||
const currentUrl = new URL(this.page.url());
|
||||
const graphId = currentUrl.searchParams.get("flowID");
|
||||
const graphVersion = Number(currentUrl.searchParams.get("flowVersion"));
|
||||
|
||||
if (!graphId || Number.isNaN(graphVersion)) {
|
||||
throw new Error(
|
||||
`Saved graph reference missing from builder URL: ${this.page.url()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { graphId, graphVersion };
|
||||
}
|
||||
|
||||
async createDummyAgent(): Promise<void> {
|
||||
await this.closeTutorial();
|
||||
await this.addBlockByClick("Add to Dictionary");
|
||||
await this.waitForNodeOnCanvas(1);
|
||||
await this.saveAgent("Test Agent", "Test Description");
|
||||
await this.waitForSaveComplete();
|
||||
}
|
||||
|
||||
// --- Happy-path flows shared across PR smoke specs ---
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.goto();
|
||||
await this.closeTutorial();
|
||||
await expect(this.page.locator(".react-flow")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(
|
||||
this.page.getByTestId("blocks-control-blocks-button"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
async addSimpleAgentBlocks(): Promise<void> {
|
||||
await this.addBlockByClick("Store Value");
|
||||
await this.waitForNodeOnCanvas(1);
|
||||
await this.fillBlockInputByPlaceholder(
|
||||
"Enter string value...",
|
||||
"smoke-value",
|
||||
0,
|
||||
);
|
||||
|
||||
await this.addBlockByClick("Add to Dictionary");
|
||||
await this.waitForNodeOnCanvas(2);
|
||||
|
||||
await this.fillNodeTextInput("Add to Dictionary", "smoke-key", 0);
|
||||
await this.fillNodeTextInput("Add to Dictionary", "smoke-value", 1);
|
||||
|
||||
// Connect Store Value's output to Add to Dictionary so the graph has a
|
||||
// real edge and actually produces output when run. Without this edge the
|
||||
// graph runs but emits no output, and `assertRunProducedOutput` rightly
|
||||
// fails — catching exactly the "I forgot to connect the blocks" bug
|
||||
// manual QA would catch.
|
||||
await this.connectNodes(0, 1);
|
||||
}
|
||||
|
||||
async createAndSaveSimpleAgent(
|
||||
prefix: string,
|
||||
): Promise<{ agentName: string; graphId: string; graphVersion: number }> {
|
||||
await this.open();
|
||||
const agentName = createUniqueAgentName(prefix);
|
||||
|
||||
await this.addSimpleAgentBlocks();
|
||||
await this.saveAgent(agentName, "PR E2E builder coverage");
|
||||
await this.waitForSaveComplete();
|
||||
await this.waitForSaveButton();
|
||||
const { graphId, graphVersion } = this.getSavedGraphRef();
|
||||
|
||||
return { agentName, graphId, graphVersion };
|
||||
}
|
||||
|
||||
async dismissSaveToast(): Promise<void> {
|
||||
const closeToastButton = this.page.getByRole("button", {
|
||||
name: "Close toast",
|
||||
});
|
||||
// Toast is optional — only shown after a save action
|
||||
if (await closeToastButton.isVisible({ timeout: 1000 })) {
|
||||
await closeToastButton.click();
|
||||
}
|
||||
|
||||
// If the toast appeared but is not yet hidden, wait for it. If it never
|
||||
// appeared at all the locator is simply hidden already — no-op.
|
||||
const savedToast = this.page.getByText("Graph saved successfully");
|
||||
if (await savedToast.isVisible({ timeout: 500 })) {
|
||||
await expect(savedToast).toBeHidden({ timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
async startRun(): Promise<void> {
|
||||
await this.clickRunButton();
|
||||
|
||||
// The run-input dialog is optional — agents without required inputs skip it
|
||||
const runDialog = this.page.locator('[data-id="run-input-dialog-content"]');
|
||||
if (await runDialog.isVisible({ timeout: 5000 })) {
|
||||
await this.page
|
||||
.locator('[data-id="run-input-manual-run-button"]')
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
async getExecutionState(): Promise<"running" | "idle" | "unknown"> {
|
||||
const stopButton = this.page.locator('[data-id="stop-graph-button"]');
|
||||
if (await stopButton.isVisible().catch(() => false)) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
const runButton = this.page.locator('[data-id="run-graph-button"]');
|
||||
if (await runButton.isVisible().catch(() => false)) {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// --- Tutorial (Shepherd.js tour) ---
|
||||
|
||||
// Each Shepherd step's <h3> title has id="<stepId>-label"; using it avoids
|
||||
// title-overlap collisions like "Open the Block Menu" vs "The Block Menu".
|
||||
private getShepherdStep(stepId: string): Locator {
|
||||
return this.page.locator(`#${stepId}-label`);
|
||||
}
|
||||
|
||||
// Scope to .shepherd-enabled so we don't click buttons on hidden-but-still-
|
||||
// attached previous steps.
|
||||
private getShepherdButton(name: string | RegExp): Locator {
|
||||
return this.page
|
||||
.locator(".shepherd-element.shepherd-enabled")
|
||||
.getByRole("button", { name });
|
||||
}
|
||||
|
||||
async startTutorial(): Promise<void> {
|
||||
// Tutorial only starts from pristine /build; a flowID query param routes
|
||||
// the tutorial button to /build?view=new instead.
|
||||
await this.page.goto("/build");
|
||||
await this.page.waitForLoadState("domcontentloaded");
|
||||
await expect(this.page.locator(".react-flow")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
window.localStorage.removeItem("shepherd-tour");
|
||||
});
|
||||
|
||||
const tutorialButton = this.page.locator('[data-id="tutorial-button"]');
|
||||
await expect(tutorialButton).toBeVisible({ timeout: 15000 });
|
||||
await expect(tutorialButton).toBeEnabled({ timeout: 15000 });
|
||||
await tutorialButton.click();
|
||||
|
||||
await expect(this.getShepherdStep("welcome")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
async walkWelcomeToBlockMenu(): Promise<void> {
|
||||
await this.getShepherdButton("Let's Begin").click();
|
||||
|
||||
await expect(this.getShepherdStep("open-block-menu")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await this.page
|
||||
.locator('[data-id="blocks-control-popover-trigger"]')
|
||||
.click();
|
||||
|
||||
await expect(this.getShepherdStep("block-menu-overview")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await this.getShepherdButton("Next").click();
|
||||
}
|
||||
|
||||
async walkSearchAndAddCalculator(): Promise<void> {
|
||||
// search-calculator auto-advances once the Calculator block card appears
|
||||
// in the filtered results; select-calculator auto-advances once the
|
||||
// Calculator is added to the node store.
|
||||
await expect(this.getShepherdStep("search-calculator")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await this.page
|
||||
.locator('[data-id="blocks-control-search-bar"] input[type="text"]')
|
||||
.fill("Calculator");
|
||||
|
||||
const calculatorCard = this.page.locator(
|
||||
'[data-id="blocks-control-search-results"] [data-id="block-card-b1ab9b1967a6406dabf52dba76d00c79"]',
|
||||
);
|
||||
await expect(calculatorCard).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await expect(this.getShepherdStep("select-calculator")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await calculatorCard.scrollIntoViewIfNeeded();
|
||||
await calculatorCard.click();
|
||||
|
||||
await expect(this.getShepherdStep("focus-new-block")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await this.waitForNodeOnCanvas(1);
|
||||
}
|
||||
|
||||
// Use dispatchEvent — the Shepherd cancel icon sits inside a step that's
|
||||
// pinned to an off-screen React Flow node, so Playwright's visibility
|
||||
// checks reject a normal click. A synthetic click event still triggers
|
||||
// tour.cancel() via Shepherd's listener.
|
||||
async cancelTutorial(): Promise<void> {
|
||||
await this.page
|
||||
.locator(".shepherd-element.shepherd-enabled .shepherd-cancel-icon")
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await expect(
|
||||
this.page.locator(".shepherd-element.shepherd-enabled"),
|
||||
).toHaveCount(0, { timeout: 10000 });
|
||||
}
|
||||
|
||||
// NOTE: welcome.ts "Skip Tutorial" only calls handleTutorialSkip, which
|
||||
// writes localStorage but does NOT call tour.cancel(). The tour UI stays
|
||||
// open — the skip state is persisted so the next /build visit knows the
|
||||
// user already dismissed the tour. Callers that want the UI closed must
|
||||
// also call cancelTutorial().
|
||||
async skipTutorialFromWelcome(): Promise<void> {
|
||||
await expect(this.getShepherdStep("welcome")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await this.getShepherdButton(/Skip Tutorial/i).click();
|
||||
await expect
|
||||
.poll(() => this.getTutorialStateFromStorage(), { timeout: 5000 })
|
||||
.toBe("skipped");
|
||||
}
|
||||
|
||||
async getTutorialStateFromStorage(): Promise<string | null> {
|
||||
return this.page.evaluate(() =>
|
||||
window.localStorage.getItem("shepherd-tour"),
|
||||
);
|
||||
}
|
||||
|
||||
async createScheduleForSavedAgent(agentName: string): Promise<void> {
|
||||
await this.dismissSaveToast();
|
||||
|
||||
const { graphId, graphVersion } = this.getSavedGraphRef();
|
||||
const scheduleName = `Daily ${agentName}`;
|
||||
const scheduleCreateUrl = `/api/proxy/api/graphs/${graphId}/schedules`;
|
||||
const timeoutAt = Date.now() + 45000;
|
||||
let lastFailure = "schedule request did not run";
|
||||
|
||||
while (Date.now() < timeoutAt) {
|
||||
const createResponse = await this.page.request.post(scheduleCreateUrl, {
|
||||
data: {
|
||||
name: scheduleName,
|
||||
graph_version: graphVersion,
|
||||
cron: "0 10 * * *",
|
||||
inputs: {},
|
||||
credentials: {},
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
const createResponseBody = await createResponse.text();
|
||||
if (createResponse.ok()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastFailure = `${createResponse.status()} ${createResponseBody}`;
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
throw new Error(`schedule creation API should succeed: ${lastFailure}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export class CopilotPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async open(sessionId?: string): Promise<void> {
|
||||
const url = sessionId ? `/copilot?sessionId=${sessionId}` : "/copilot";
|
||||
await this.page.goto(url);
|
||||
await expect(this.page).toHaveURL(/\/copilot/);
|
||||
await this.dismissNotificationPrompt();
|
||||
}
|
||||
|
||||
async dismissNotificationPrompt(): Promise<void> {
|
||||
// Notification permission prompt is optional — only shown on first visit
|
||||
const notNowButton = this.page.getByRole("button", { name: "Not now" });
|
||||
if (await notNowButton.isVisible({ timeout: 3000 })) {
|
||||
await notNowButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
async createSessionViaApi(): Promise<string> {
|
||||
const response = await this.page.request.post(
|
||||
"/api/proxy/api/chat/sessions",
|
||||
{ data: null },
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const session = await response.json();
|
||||
const sessionId = session?.id;
|
||||
expect(sessionId).toBeTruthy();
|
||||
return sessionId as string;
|
||||
}
|
||||
|
||||
getChatInput(): Locator {
|
||||
return this.page.locator("#chat-input-session");
|
||||
}
|
||||
|
||||
async waitForChatInput(): Promise<void> {
|
||||
await expect(this.getChatInput()).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
}
|
||||
1342
autogpt_platform/frontend/src/playwright/pages/library.page.ts
Normal file
1342
autogpt_platform/frontend/src/playwright/pages/library.page.ts
Normal file
File diff suppressed because it is too large
Load Diff
123
autogpt_platform/frontend/src/playwright/pages/login.page.ts
Normal file
123
autogpt_platform/frontend/src/playwright/pages/login.page.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import {
|
||||
getSeededTestUser,
|
||||
type SeededTestAccountKey,
|
||||
} from "../credentials/accounts";
|
||||
import { skipOnboardingIfPresent } from "../utils/onboarding";
|
||||
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login");
|
||||
}
|
||||
|
||||
async loginAsSeededUser(userKey: SeededTestAccountKey): Promise<void> {
|
||||
const user = getSeededTestUser(userKey);
|
||||
await this.page.goto("/login");
|
||||
await this.login(user.email, user.password);
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
console.log(`ℹ️ Attempting login on ${this.page.url()} with`, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Wait for the form to be ready
|
||||
await this.page.waitForSelector("form", { state: "visible" });
|
||||
|
||||
// Fill email using input selector instead of label
|
||||
const emailInput = this.page.locator('input[type="email"]');
|
||||
await emailInput.waitFor({ state: "visible" });
|
||||
await emailInput.fill(email);
|
||||
|
||||
// Fill password using input selector instead of label
|
||||
const passwordInput = this.page.locator('input[type="password"]');
|
||||
await passwordInput.waitFor({ state: "visible" });
|
||||
await passwordInput.fill(password);
|
||||
|
||||
// Wait for the button to be ready
|
||||
const loginButton = this.page.getByRole("button", {
|
||||
name: "Login",
|
||||
exact: true,
|
||||
});
|
||||
await loginButton.waitFor({ state: "visible" });
|
||||
|
||||
// Attach navigation logger for debug purposes
|
||||
this.page.once("load", (page) =>
|
||||
console.log(`ℹ️ Now at URL: ${page.url()}`),
|
||||
);
|
||||
|
||||
const hasReachedPostLoginRoute = () =>
|
||||
this.page.waitForFunction(
|
||||
() => {
|
||||
const pathname = window.location.pathname;
|
||||
return /^\/(marketplace|onboarding(\/.*)?|library|copilot)$/.test(
|
||||
pathname,
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
console.log(`🖱️ Clicking login button...`);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await loginButton.click();
|
||||
|
||||
console.log("⏳ Waiting for navigation away from /login ...");
|
||||
try {
|
||||
await hasReachedPostLoginRoute();
|
||||
break;
|
||||
} catch (reason) {
|
||||
const currentPathname = new URL(this.page.url()).pathname;
|
||||
if (attempt === 1 || currentPathname !== "/login") {
|
||||
console.error(
|
||||
`🚨 Navigation away from /login timed out (current URL: ${this.page.url()}):`,
|
||||
reason,
|
||||
);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`⌛ Post-login redirected to ${this.page.url()}`);
|
||||
|
||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||
|
||||
// If redirected to onboarding, complete it via API so tests can proceed
|
||||
await skipOnboardingIfPresent(this.page, "/marketplace");
|
||||
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 20_000 });
|
||||
console.log("✅ Login process complete");
|
||||
|
||||
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions.
|
||||
// The popover is genuinely optional — only appears on some accounts/environments.
|
||||
const walletPanel = this.page.getByText("Your credits").first();
|
||||
const walletPanelVisible = await walletPanel
|
||||
.waitFor({ state: "visible", timeout: 2500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (walletPanelVisible) {
|
||||
const closeWalletButton = this.page.getByRole("button", {
|
||||
name: /Close wallet/i,
|
||||
});
|
||||
const closeWalletButtonVisible = await closeWalletButton
|
||||
.waitFor({ state: "visible", timeout: 1000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (closeWalletButtonVisible) {
|
||||
await closeWalletButton.click();
|
||||
} else {
|
||||
await this.page.keyboard.press("Escape");
|
||||
}
|
||||
const walletStillVisible = await walletPanel
|
||||
.waitFor({ state: "hidden", timeout: 3000 })
|
||||
.then(() => false)
|
||||
.catch(() => true);
|
||||
if (walletStillVisible) {
|
||||
await this.page.mouse.click(5, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
import { dismissFeedbackDialog } from "./library.page";
|
||||
import { getSelectors } from "../utils/selectors";
|
||||
|
||||
const DETERMINISTIC_MARKETPLACE_AGENT_SEARCH = "E2E Calculator Agent";
|
||||
|
||||
export class MarketplacePage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async goto(page: Page) {
|
||||
await page.goto("/marketplace");
|
||||
await page
|
||||
.locator(
|
||||
'[data-testid="store-card"], [data-testid="featured-store-card"]',
|
||||
)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 20000 });
|
||||
}
|
||||
|
||||
async getMarketplaceTitle(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Explore AI agents", { exact: false });
|
||||
}
|
||||
|
||||
async getCreatorsSection(page: Page) {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
return getId("creators-section") || getText("Creators", { exact: false });
|
||||
}
|
||||
|
||||
async getAgentsSection(page: Page) {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
return getId("agents-section") || getText("Agents", { exact: false });
|
||||
}
|
||||
|
||||
async getCreatorsLink(page: Page) {
|
||||
const { getLink } = getSelectors(page);
|
||||
return getLink(/creators/i);
|
||||
}
|
||||
|
||||
async getAgentsLink(page: Page) {
|
||||
const { getLink } = getSelectors(page);
|
||||
return getLink(/agents/i);
|
||||
}
|
||||
|
||||
async getSearchInput(page: Page) {
|
||||
const visibleSearchInput = page
|
||||
.locator('[data-testid="store-search-input"]:visible')
|
||||
.first();
|
||||
if (await visibleSearchInput.isVisible().catch(() => false)) {
|
||||
return visibleSearchInput;
|
||||
}
|
||||
|
||||
const { getField, getId } = getSelectors(page);
|
||||
return getId("store-search-input").first() || getField(/search/i).first();
|
||||
}
|
||||
|
||||
async getFilterDropdown(page: Page) {
|
||||
const { getId, getButton } = getSelectors(page);
|
||||
return getId("filter-dropdown") || getButton(/filter/i);
|
||||
}
|
||||
|
||||
async searchFor(query: string, page: Page) {
|
||||
const searchInput = await this.getSearchInput(page);
|
||||
await searchInput.fill(query);
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async clickCreators(page: Page) {
|
||||
const creatorsLink = await this.getCreatorsLink(page);
|
||||
await creatorsLink.click();
|
||||
}
|
||||
|
||||
async clickAgents(page: Page) {
|
||||
const agentsLink = await this.getAgentsLink(page);
|
||||
await agentsLink.click();
|
||||
}
|
||||
|
||||
async openFilter(page: Page) {
|
||||
const filterDropdown = await this.getFilterDropdown(page);
|
||||
await filterDropdown.click();
|
||||
}
|
||||
|
||||
async getFeaturedAgentsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Featured agents");
|
||||
}
|
||||
|
||||
async getTopAgentsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("All Agents");
|
||||
}
|
||||
|
||||
async getFeaturedCreatorsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Featured Creators");
|
||||
}
|
||||
|
||||
async getFeaturedAgentCards(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("featured-store-card");
|
||||
}
|
||||
|
||||
async getTopAgentCards(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("store-card");
|
||||
}
|
||||
|
||||
async getCreatorProfiles(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("creator-card");
|
||||
}
|
||||
|
||||
async searchAndNavigate(query: string, page: Page) {
|
||||
const searchInput = (await this.getSearchInput(page)).first();
|
||||
await searchInput.fill(query);
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async waitForSearchResults() {
|
||||
await this.page.waitForURL("**/marketplace/search**");
|
||||
}
|
||||
|
||||
async getFirstFeaturedAgent(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const card = getId("featured-store-card").first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getFirstTopAgent() {
|
||||
const card = this.page
|
||||
.locator('[data-testid="store-card"]:visible')
|
||||
.first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getFirstCreatorProfile(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const card = getId("creator-card").first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getSearchResultsCount(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const storeCards = getId("store-card");
|
||||
return await storeCards.count();
|
||||
}
|
||||
|
||||
// --- Happy-path flows shared across PR smoke specs ---
|
||||
|
||||
async openRunnableAgent(): Promise<{ path: string }> {
|
||||
await this.searchAndOpenAgent(DETERMINISTIC_MARKETPLACE_AGENT_SEARCH);
|
||||
|
||||
await expect(this.page.getByTestId("agent-add-library-button")).toBeVisible(
|
||||
{
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
|
||||
return { path: this.page.url() };
|
||||
}
|
||||
|
||||
async openFeaturedAgent(): Promise<void> {
|
||||
await this.searchAndOpenAgent(DETERMINISTIC_MARKETPLACE_AGENT_SEARCH);
|
||||
await dismissFeedbackDialog(this.page);
|
||||
}
|
||||
|
||||
private async searchAndOpenAgent(agentName: string): Promise<void> {
|
||||
const searchURL = `/marketplace/search?searchTerm=${encodeURIComponent(agentName)}`;
|
||||
|
||||
const agentCard = this.page
|
||||
.locator('[data-testid="store-card"]:visible')
|
||||
.filter({ hasText: agentName })
|
||||
.first();
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await this.page.goto(searchURL);
|
||||
await this.page.waitForLoadState("networkidle");
|
||||
|
||||
const visible = await agentCard
|
||||
.waitFor({ state: "visible", timeout: 15000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (visible) break;
|
||||
|
||||
if (attempt === 2) {
|
||||
await expect(agentCard).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
await agentCard.click();
|
||||
|
||||
await expect(this.page).toHaveURL(/\/marketplace\/agent\//, {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(this.page.getByTestId("agent-title")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
async submitAgentForReview(publishableAgentName: string): Promise<{
|
||||
agentTitle: string;
|
||||
agentSlug: string;
|
||||
}> {
|
||||
await this.page.goto("/marketplace");
|
||||
await this.page.getByRole("button", { name: "Become a Creator" }).click();
|
||||
|
||||
const publishAgentModal = this.page.getByTestId("publish-agent-modal");
|
||||
await expect(publishAgentModal).toBeVisible();
|
||||
await expect(
|
||||
publishAgentModal.getByText(
|
||||
"Select your project that you'd like to publish",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const publishableAgentCard = publishAgentModal
|
||||
.getByTestId("agent-card")
|
||||
.filter({ hasText: publishableAgentName })
|
||||
.first();
|
||||
await expect(publishableAgentCard).toBeVisible({ timeout: 15000 });
|
||||
await publishableAgentCard.click();
|
||||
await publishAgentModal
|
||||
.getByRole("button", { name: "Next", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
publishAgentModal.getByText("Write a bit of details about your agent"),
|
||||
).toBeVisible();
|
||||
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const agentTitle = `Publish Flow ${suffix}`;
|
||||
const agentSlug = `publish-flow-${suffix}`;
|
||||
|
||||
await publishAgentModal.getByLabel("Title").fill(agentTitle);
|
||||
await publishAgentModal
|
||||
.getByLabel("Subheader")
|
||||
.fill("A deterministic marketplace submission");
|
||||
await publishAgentModal.getByLabel("Slug").fill(agentSlug);
|
||||
await publishAgentModal
|
||||
.getByLabel("YouTube video link")
|
||||
.fill("https://www.youtube.com/watch?v=test123");
|
||||
|
||||
await publishAgentModal.getByRole("combobox", { name: "Category" }).click();
|
||||
await this.page.getByRole("option", { name: "Other" }).click();
|
||||
|
||||
await publishAgentModal
|
||||
.getByLabel("Description")
|
||||
.fill(
|
||||
"A deterministic publish flow for consolidated Playwright coverage.",
|
||||
);
|
||||
|
||||
const submitButton = publishAgentModal.getByRole("button", {
|
||||
name: "Submit for review",
|
||||
});
|
||||
await expect(submitButton).toBeEnabled();
|
||||
await submitButton.click();
|
||||
|
||||
await expect(
|
||||
publishAgentModal.getByText("Agent is awaiting review"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
publishAgentModal.getByTestId("view-progress-button"),
|
||||
).toBeVisible();
|
||||
|
||||
return { agentTitle, agentSlug };
|
||||
}
|
||||
|
||||
async waitForDashboardSubmission(agentTitle: string) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const submissionRow = this.page
|
||||
.getByTestId("agent-table-row")
|
||||
.filter({ hasText: agentTitle })
|
||||
.first();
|
||||
|
||||
// Row may not appear immediately after redirect — allow a short render
|
||||
// window before deciding the submission is absent on this attempt.
|
||||
if (await submissionRow.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
return submissionRow;
|
||||
}
|
||||
|
||||
await this.page.reload();
|
||||
await expect(this.page).toHaveURL(/\/profile\/dashboard/);
|
||||
await expect(this.page.getByText("Agent dashboard")).toBeVisible();
|
||||
}
|
||||
|
||||
throw new Error(`Submission row for "${agentTitle}" did not appear`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export class SettingsPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.page.goto("/profile/settings");
|
||||
await expect(this.page).toHaveURL(/\/profile\/settings/);
|
||||
await expect(
|
||||
this.page.getByText("Manage your account settings and preferences."),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
getAgentRunNotificationsSwitch(): Locator {
|
||||
return this.page.getByRole("switch", {
|
||||
name: "Agent Run Notifications",
|
||||
});
|
||||
}
|
||||
|
||||
async savePreferences(): Promise<void> {
|
||||
await this.page.getByRole("button", { name: "Save preferences" }).click();
|
||||
await expect(
|
||||
this.page.getByText("Successfully updated notification preferences"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { E2E_AUTH_STATES } from "./credentials/accounts";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { LibraryPage } from "./pages/library.page";
|
||||
import { MarketplacePage } from "./pages/marketplace.page";
|
||||
|
||||
test.use({ storageState: E2E_AUTH_STATES.parallelA });
|
||||
|
||||
test("publish happy path: user can submit, track, and delete an agent submission from the dashboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
const buildPage = new BuildPage(page);
|
||||
const libraryPage = new LibraryPage(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
const { agentName: publishableAgentName } =
|
||||
await buildPage.createAndSaveSimpleAgent("Publish Flow Agent");
|
||||
|
||||
await page.goto("/library");
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
await libraryPage.searchAgents(publishableAgentName);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const createdAgent = page
|
||||
.getByTestId("library-agent-card")
|
||||
.filter({ hasText: publishableAgentName })
|
||||
.first();
|
||||
await expect(createdAgent).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const { agentTitle, agentSlug } =
|
||||
await marketplacePage.submitAgentForReview(publishableAgentName);
|
||||
|
||||
await page.getByTestId("view-progress-button").click();
|
||||
await expect(page).toHaveURL(/\/profile\/dashboard/);
|
||||
await expect(page.getByText("Agent dashboard")).toBeVisible();
|
||||
|
||||
const submissionRow =
|
||||
await marketplacePage.waitForDashboardSubmission(agentTitle);
|
||||
await expect(
|
||||
submissionRow.getByTestId("agent-status"),
|
||||
`submission "${agentTitle}" should appear in the dashboard review-pending state`,
|
||||
).toContainText(/awaiting review/i);
|
||||
await submissionRow.getByTestId("agent-table-row-actions").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
|
||||
// Delete the submission via the actions menu. The dashboard does not show
|
||||
// a confirmation dialog — clicking Delete fires the API directly. We then
|
||||
// assert the row is gone, proving the backend actually removed it (not
|
||||
// just the menu item disappeared).
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-table-row").filter({ hasText: agentTitle }),
|
||||
`submission row "${agentTitle}" must be removed from the dashboard after delete`,
|
||||
).toHaveCount(0, { timeout: 15000 });
|
||||
|
||||
// Validate the deleted submission is no longer discoverable in Marketplace.
|
||||
await page.goto("/marketplace");
|
||||
const searchInput = page
|
||||
.locator('[data-testid="store-search-input"]:visible')
|
||||
.first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill(agentSlug);
|
||||
await searchInput.press("Enter");
|
||||
await expect(page).toHaveURL(/\/marketplace\/search/);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="store-card"], [data-testid="featured-store-card"]',
|
||||
)
|
||||
.filter({ hasText: agentTitle }),
|
||||
`deleted submission "${agentTitle}" should not appear in marketplace results`,
|
||||
).toHaveCount(0, { timeout: 15000 });
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { ProfileFormPage } from "./pages/profile-form.page";
|
||||
import { SettingsPage } from "./pages/settings.page";
|
||||
|
||||
test("settings happy path: user can save notification preferences and keep them after reload and re-login", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
const settingsPage = new SettingsPage(page);
|
||||
|
||||
await loginPage.loginAsSeededUser("smokeSettings");
|
||||
await settingsPage.open();
|
||||
|
||||
const agentRunSwitch = settingsPage.getAgentRunNotificationsSwitch();
|
||||
// Assert the attribute exists before reading it — defaulting to "false"
|
||||
// would silently pass a regression that removes `aria-checked` entirely.
|
||||
await expect(agentRunSwitch).toHaveAttribute(
|
||||
"aria-checked",
|
||||
/^(true|false)$/,
|
||||
);
|
||||
const initialState = await agentRunSwitch.getAttribute("aria-checked");
|
||||
const expectedState = initialState === "true" ? "false" : "true";
|
||||
|
||||
await agentRunSwitch.click();
|
||||
await settingsPage.savePreferences();
|
||||
await expect(agentRunSwitch).toHaveAttribute("aria-checked", expectedState);
|
||||
|
||||
await page.reload();
|
||||
await settingsPage.open();
|
||||
await expect(settingsPage.getAgentRunNotificationsSwitch()).toHaveAttribute(
|
||||
"aria-checked",
|
||||
expectedState,
|
||||
);
|
||||
|
||||
await page.getByTestId("profile-popout-menu-trigger").click();
|
||||
await page.getByRole("button", { name: "Log out" }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
await loginPage.loginAsSeededUser("smokeSettings");
|
||||
await settingsPage.open();
|
||||
await expect(settingsPage.getAgentRunNotificationsSwitch()).toHaveAttribute(
|
||||
"aria-checked",
|
||||
expectedState,
|
||||
);
|
||||
});
|
||||
|
||||
test("settings happy path: user can edit display name and keep it after refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
const profileFormPage = new ProfileFormPage(page);
|
||||
const updatedDisplayName = `E2E Display ${Date.now()}`;
|
||||
|
||||
await loginPage.loginAsSeededUser("smokeSettings");
|
||||
await page.goto("/profile");
|
||||
await expect(await profileFormPage.isLoaded()).toBe(true);
|
||||
|
||||
await profileFormPage.setDisplayName(updatedDisplayName);
|
||||
await profileFormPage.saveChanges();
|
||||
|
||||
await expect
|
||||
.poll(() => profileFormPage.getDisplayName(), { timeout: 15000 })
|
||||
.toBe(updatedDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await expect(await profileFormPage.isLoaded()).toBe(true);
|
||||
await expect
|
||||
.poll(() => profileFormPage.getDisplayName(), { timeout: 15000 })
|
||||
.toBe(updatedDisplayName);
|
||||
});
|
||||
284
autogpt_platform/frontend/src/playwright/utils/auth.ts
Normal file
284
autogpt_platform/frontend/src/playwright/utils/auth.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
import {
|
||||
SEEDED_AUTH_STATE_ACCOUNT_KEYS,
|
||||
SEEDED_TEST_ACCOUNTS,
|
||||
SEEDED_TEST_USERS,
|
||||
getAuthStatePath,
|
||||
} from "../credentials/accounts";
|
||||
import { buildCookieConsentStorageState } from "../credentials/storage-state";
|
||||
import { signupTestUser } from "./signup";
|
||||
import { getBrowser } from "./get-browser";
|
||||
import { skipOnboardingIfPresent } from "./onboarding";
|
||||
|
||||
export interface TestUser {
|
||||
email: string;
|
||||
password: string;
|
||||
id?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface UserPool {
|
||||
users: TestUser[];
|
||||
createdAt: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const AUTH_STATE_KEYS = [...SEEDED_AUTH_STATE_ACCOUNT_KEYS];
|
||||
|
||||
export async function createTestUser(
|
||||
email?: string,
|
||||
password?: string,
|
||||
ignoreOnboarding: boolean = true,
|
||||
): Promise<TestUser> {
|
||||
const { faker } = await import("@faker-js/faker");
|
||||
const userEmail = email || faker.internet.email();
|
||||
const userPassword = password || faker.internet.password({ length: 12 });
|
||||
|
||||
try {
|
||||
const browser = await getBrowser();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Auto-accept cookies in test environment to prevent banner from appearing
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem(
|
||||
"autogpt_cookie_consent",
|
||||
JSON.stringify({
|
||||
hasConsented: true,
|
||||
timestamp: Date.now(),
|
||||
analytics: true,
|
||||
monitoring: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const testUser = await signupTestUser(
|
||||
page,
|
||||
userEmail,
|
||||
userPassword,
|
||||
ignoreOnboarding,
|
||||
false,
|
||||
);
|
||||
return testUser;
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating test user ${userEmail}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestUsers(count: number): Promise<TestUser[]> {
|
||||
console.log(`👥 Creating ${count} test users...`);
|
||||
|
||||
const users: TestUser[] = [];
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const user = await createTestUser();
|
||||
users.push(user);
|
||||
consecutiveFailures = 0; // Reset failure counter on success
|
||||
console.log(`✅ Created user ${i + 1}/${count}: ${user.email}`);
|
||||
} catch (error) {
|
||||
consecutiveFailures++;
|
||||
console.error(`❌ Failed to create user ${i + 1}/${count}:`, error);
|
||||
|
||||
// If we have too many consecutive failures, stop trying
|
||||
if (consecutiveFailures >= 3) {
|
||||
console.error(
|
||||
`⚠️ Stopping after ${consecutiveFailures} consecutive failures`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Successfully created ${users.length}/${count} test users`);
|
||||
return users;
|
||||
}
|
||||
|
||||
export async function getTestUser(accountKey?: string): Promise<TestUser> {
|
||||
if (SEEDED_TEST_USERS.length === 0) {
|
||||
throw new Error("No seeded E2E users are configured");
|
||||
}
|
||||
|
||||
if (accountKey) {
|
||||
const matchedUser = SEEDED_TEST_USERS.find(
|
||||
(user) => user.key === accountKey || user.email === accountKey,
|
||||
);
|
||||
|
||||
if (!matchedUser) {
|
||||
throw new Error(
|
||||
`No seeded E2E user found for account key or email: ${accountKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { email: matchedUser.email, password: matchedUser.password };
|
||||
}
|
||||
|
||||
const rawWorkerIndex = Number.parseInt(
|
||||
process.env.TEST_WORKER_INDEX ?? process.env.PLAYWRIGHT_WORKER_INDEX ?? "0",
|
||||
10,
|
||||
);
|
||||
const workerIndex = Number.isNaN(rawWorkerIndex) ? 0 : rawWorkerIndex;
|
||||
const deterministicIndex =
|
||||
((workerIndex % SEEDED_TEST_USERS.length) + SEEDED_TEST_USERS.length) %
|
||||
SEEDED_TEST_USERS.length;
|
||||
const { email, password } = SEEDED_TEST_USERS[deterministicIndex];
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
function hasStoredAuthState(accountKey: (typeof AUTH_STATE_KEYS)[number]) {
|
||||
return fs.existsSync(getAuthStatePath(accountKey));
|
||||
}
|
||||
|
||||
function authStateMatchesOrigin(
|
||||
accountKey: (typeof AUTH_STATE_KEYS)[number],
|
||||
origin: string,
|
||||
): boolean {
|
||||
const statePath = getAuthStatePath(accountKey);
|
||||
if (!fs.existsSync(statePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(statePath, "utf8")) as {
|
||||
origins?: Array<{ origin?: string }>;
|
||||
};
|
||||
return (
|
||||
state.origins?.some((storedOrigin) => storedOrigin.origin === origin) ??
|
||||
false
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSeededAuthStates(baseURL: string): boolean {
|
||||
const origin = new URL(baseURL).origin;
|
||||
return AUTH_STATE_KEYS.every(
|
||||
(accountKey) =>
|
||||
hasStoredAuthState(accountKey) &&
|
||||
authStateMatchesOrigin(accountKey, origin),
|
||||
);
|
||||
}
|
||||
|
||||
async function authStateHasLiveSession(
|
||||
baseURL: string,
|
||||
accountKey: (typeof AUTH_STATE_KEYS)[number],
|
||||
): Promise<boolean> {
|
||||
const browser = await getBrowser();
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
storageState: getAuthStatePath(accountKey),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/marketplace");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await skipOnboardingIfPresent(page, "/marketplace");
|
||||
return await page
|
||||
.getByTestId("profile-popout-menu-trigger")
|
||||
.waitFor({ state: "visible", timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvalidSeededAuthStateKeys(
|
||||
baseURL: string,
|
||||
): Promise<(typeof AUTH_STATE_KEYS)[number][]> {
|
||||
const origin = new URL(baseURL).origin;
|
||||
const invalidKeys = await Promise.all(
|
||||
AUTH_STATE_KEYS.map(async (accountKey) => {
|
||||
if (
|
||||
!hasStoredAuthState(accountKey) ||
|
||||
!authStateMatchesOrigin(accountKey, origin)
|
||||
) {
|
||||
return accountKey;
|
||||
}
|
||||
|
||||
return (await authStateHasLiveSession(baseURL, accountKey))
|
||||
? null
|
||||
: accountKey;
|
||||
}),
|
||||
);
|
||||
|
||||
return invalidKeys.filter(
|
||||
(accountKey): accountKey is (typeof AUTH_STATE_KEYS)[number] =>
|
||||
accountKey !== null,
|
||||
);
|
||||
}
|
||||
|
||||
async function createAuthStateForUser(
|
||||
baseURL: string,
|
||||
accountKey: (typeof AUTH_STATE_KEYS)[number],
|
||||
): Promise<void> {
|
||||
const browser = await getBrowser();
|
||||
|
||||
try {
|
||||
const { email, password } = SEEDED_TEST_ACCOUNTS[accountKey];
|
||||
const origin = new URL(baseURL).origin;
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
storageState: buildCookieConsentStorageState(origin),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(email, password);
|
||||
await page.waitForURL(
|
||||
(url: URL) =>
|
||||
/\/(onboarding|marketplace|copilot|library)/.test(url.pathname),
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await skipOnboardingIfPresent(page, "/marketplace");
|
||||
await page.getByTestId("profile-popout-menu-trigger").waitFor({
|
||||
state: "visible",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const statePath = getAuthStatePath(accountKey);
|
||||
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||
await context.storageState({ path: statePath });
|
||||
await context.close();
|
||||
} catch (error) {
|
||||
const { email } = SEEDED_TEST_ACCOUNTS[accountKey];
|
||||
throw new Error(
|
||||
`Failed to create auth state for ${email}: ${String(
|
||||
error,
|
||||
)}. If these seeded QA accounts are missing, seed them with backend/test/e2e_test_data.py before running Playwright.`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSeededAuthStates(baseURL: string): Promise<void> {
|
||||
const invalidKeys = await getInvalidSeededAuthStateKeys(baseURL);
|
||||
|
||||
await Promise.all(
|
||||
invalidKeys.map((accountKey) =>
|
||||
createAuthStateForUser(baseURL, accountKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Page, expect } from "@playwright/test";
|
||||
|
||||
function resolveAppUrl(page: Page, destination: string) {
|
||||
const baseURL =
|
||||
page.url().startsWith("http://") || page.url().startsWith("https://")
|
||||
? page.url()
|
||||
: (process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000");
|
||||
|
||||
return new URL(destination, baseURL).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the onboarding wizard via API.
|
||||
* Use this when a test needs an authenticated user who has already finished onboarding
|
||||
@@ -10,8 +19,11 @@ import { Page, expect } from "@playwright/test";
|
||||
*/
|
||||
export async function completeOnboardingViaAPI(page: Page) {
|
||||
await page.request.post(
|
||||
"http://localhost:3000/api/proxy/api/onboarding/step?step=VISIT_COPILOT",
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
resolveAppUrl(page, "/api/proxy/api/onboarding/step"),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
params: { step: "VISIT_COPILOT" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +40,7 @@ export async function skipOnboardingIfPresent(
|
||||
if (!url.includes("/onboarding")) return;
|
||||
|
||||
await completeOnboardingViaAPI(page);
|
||||
await page.goto(`http://localhost:3000${destination}`);
|
||||
await page.goto(resolveAppUrl(page, destination));
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
|
||||
}
|
||||
|
||||
@@ -70,8 +82,15 @@ export async function completeOnboardingWizard(
|
||||
}
|
||||
await page.getByRole("button", { name: "Launch Autopilot" }).click();
|
||||
|
||||
// Step 4: Preparing — wait for animation to complete and redirect to /copilot
|
||||
await page.waitForURL(/\/copilot/, { timeout: 15000 });
|
||||
// Step 4: Preparing — require the real transition state to appear first,
|
||||
// then wait for the app shell on /copilot rather than racing the redirect.
|
||||
await expect(
|
||||
page.getByText("Preparing your workspace...", { exact: false }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForURL(/\/copilot/, { timeout: 30000 });
|
||||
await expect(page.getByTestId("profile-popout-menu-trigger")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
return { name, role, painPoints };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function signupTestUser(
|
||||
|
||||
try {
|
||||
// Navigate to signup page
|
||||
await page.goto("http://localhost:3000/signup");
|
||||
await page.goto("/signup");
|
||||
|
||||
// Wait for page to load
|
||||
getText("Create a new account");
|
||||
@@ -122,7 +122,7 @@ export async function signupAndNavigateToMarketplace(
|
||||
export async function validateSignupForm(page: any): Promise<void> {
|
||||
console.log("🧪 Validating signup form...");
|
||||
|
||||
await page.goto("http://localhost:3000/signup");
|
||||
await page.goto("/signup");
|
||||
|
||||
// Test empty form submission
|
||||
console.log("❌ Testing empty form submission...");
|
||||
@@ -22,7 +22,7 @@
|
||||
- Flows requiring real browser APIs (clipboard, downloads)
|
||||
- Cross-page navigation that must work end-to-end
|
||||
|
||||
**Location:** `src/tests/*.spec.ts` (centralized, as there will be fewer of them)
|
||||
**Location:** `src/playwright/*.spec.ts` (centralized, as there will be fewer of them)
|
||||
|
||||
**Import:** Always import `test` and `expect` from `./coverage-fixture` instead of `@playwright/test`. This auto-collects V8 coverage per test for Codecov reporting.
|
||||
|
||||
@@ -74,6 +74,10 @@ Start with a `main.test.tsx` file and split into smaller files as it grows.
|
||||
2. Mock API requests via MSW
|
||||
3. Assert UI scenarios via Testing Library
|
||||
|
||||
**Prefer the UI surface over direct hook tests:** if a `use*.ts` hook only exists to support a page/component, test that page/component instead of adding a `renderHook()` test. Reserve direct hook tests for shared hooks with standalone business logic that cannot be exercised cleanly through the UI.
|
||||
|
||||
**Prefer Orval-generated mocks:** use the generated MSW handlers and response builders from `src/app/api/__generated__/endpoints/*/*.msw.ts` instead of hand-built API response objects or mocking a page/component hook.
|
||||
|
||||
```tsx
|
||||
// Example: Test page renders data from API
|
||||
import { server } from "@/mocks/mock-server";
|
||||
@@ -98,7 +102,7 @@ test("shows error when submission fails", async () => {
|
||||
- Pure utility functions (`lib/utils.ts`)
|
||||
- Component rendering with various props
|
||||
- Component state changes
|
||||
- Custom hooks
|
||||
- Shared hooks with standalone business logic
|
||||
|
||||
**Location:** Co-located with the file: `Component.test.tsx` next to `Component.tsx`
|
||||
|
||||
@@ -172,25 +176,29 @@ src/
|
||||
├── mocks/
|
||||
│ ├── mock-handlers.ts # MSW handlers (auto-generated via Orval)
|
||||
│ └── mock-server.ts # MSW server setup
|
||||
├── playwright/
|
||||
│ ├── *.spec.ts # E2E tests (Playwright) - centralized
|
||||
│ ├── pages/ # Playwright page objects
|
||||
│ └── utils/ # Playwright helpers/fixtures
|
||||
└── tests/
|
||||
├── integrations/
|
||||
│ ├── test-utils.tsx # Testing utilities
|
||||
│ └── vitest.setup.tsx # Integration test setup
|
||||
└── *.spec.ts # E2E tests (Playwright) - centralized
|
||||
└── AGENTS.md # Testing guidance for agents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
| Component Type | Test Priority | Recommended Test |
|
||||
| ------------------- | ------------- | ---------------- |
|
||||
| Pages/Features | **Highest** | Integration |
|
||||
| Custom Hooks | High | Unit |
|
||||
| Utility Functions | High | Unit |
|
||||
| Organisms (complex) | High | Integration |
|
||||
| Molecules | Medium | Unit + Storybook |
|
||||
| Atoms | Medium | Storybook only\* |
|
||||
| Component Type | Test Priority | Recommended Test |
|
||||
| ------------------- | ------------- | -------------------------------------- |
|
||||
| Pages/Features | **Highest** | Integration |
|
||||
| Custom Hooks | Medium | Parent integration or shared-hook unit |
|
||||
| Utility Functions | High | Unit |
|
||||
| Organisms (complex) | High | Integration |
|
||||
| Molecules | Medium | Unit + Storybook |
|
||||
| Atoms | Medium | Storybook only\* |
|
||||
|
||||
\*Atoms are typically simple enough that Storybook visual tests suffice.
|
||||
|
||||
@@ -218,6 +226,8 @@ test("shows error when deletion fails", async () => {
|
||||
|
||||
**Generated handlers location:** `src/app/api/__generated__/endpoints/*/` - each endpoint has handlers for different status codes.
|
||||
|
||||
For Playwright support code, keep browser-only helpers in `src/playwright/` rather than `src/tests/`.
|
||||
|
||||
---
|
||||
|
||||
## Golden Rules
|
||||
@@ -228,3 +238,5 @@ test("shows error when deletion fails", async () => {
|
||||
4. **Co-locate integration tests** - Keep `__tests__/` folder next to the component
|
||||
5. **E2E is expensive** - Only for critical happy paths; prefer integration tests
|
||||
6. **AI agents are good at writing integration tests** - Start with these when adding test coverage
|
||||
7. **Prefer component/page tests over hook tests** - Don't add `renderHook()` coverage for component implementation details
|
||||
8. **Use generated API mocks** - Prefer Orval MSW helpers over manual API object stubs
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import * as LibraryPage from "./pages/library.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasTextContent, hasUrl, isVisible } from "./utils/assertion";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const buildPage = new BuildPage(page);
|
||||
const testUser = await getTestUser();
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/build");
|
||||
await buildPage.closeTutorial();
|
||||
|
||||
await buildPage.addBlockByClick("Add to Dictionary");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
await buildPage.saveAgent("Test Agent", "Test Description");
|
||||
await test
|
||||
.expect(page)
|
||||
.toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
|
||||
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto("/library");
|
||||
// Navigate to the specific agent we just created, not just the first one
|
||||
await LibraryPage.navigateToAgentByName(page, "Test Agent");
|
||||
await LibraryPage.waitForAgentPageLoad(page);
|
||||
});
|
||||
|
||||
test("shows badge with count when agent is running", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
|
||||
// Start the agent run
|
||||
await LibraryPage.clickRunButton(page);
|
||||
|
||||
// Wait for the badge to appear and check it has a valid count
|
||||
const badge = getId("agent-activity-badge");
|
||||
await isVisible(badge);
|
||||
|
||||
// Check that badge shows a positive number (more flexible than exact count)
|
||||
await expect(async () => {
|
||||
const badgeText = await badge.textContent();
|
||||
const count = parseInt(badgeText || "0");
|
||||
|
||||
if (count < 1) {
|
||||
throw new Error(`Expected badge count >= 1, got: ${badgeText}`);
|
||||
}
|
||||
}).toPass({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("displays the runs on the activity dropdown", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
|
||||
const activityBtn = getId("agent-activity-button");
|
||||
await isVisible(activityBtn);
|
||||
|
||||
// Start the agent run
|
||||
await LibraryPage.clickRunButton(page);
|
||||
|
||||
// Wait for the activity badge to appear (indicating execution started)
|
||||
const badge = getId("agent-activity-badge");
|
||||
await isVisible(badge);
|
||||
|
||||
// Click to open the dropdown
|
||||
await activityBtn.click();
|
||||
|
||||
const dropdown = getId("agent-activity-dropdown");
|
||||
await isVisible(dropdown);
|
||||
|
||||
// Check that the agent name appears in the dropdown
|
||||
await hasTextContent(dropdown, "Test Agent");
|
||||
|
||||
// Check for execution status - be more flexible with text matching
|
||||
await expect(async () => {
|
||||
const dropdownText = await dropdown.textContent();
|
||||
const hasAgentName = dropdownText?.includes("Test Agent");
|
||||
const hasExecutionStatus =
|
||||
dropdownText?.includes("queued") ||
|
||||
dropdownText?.includes("running") ||
|
||||
dropdownText?.includes("Started");
|
||||
|
||||
if (!hasAgentName || !hasExecutionStatus) {
|
||||
throw new Error(
|
||||
`Expected agent name and execution status, got: ${dropdownText}`,
|
||||
);
|
||||
}
|
||||
}).toPass({ timeout: 8000 });
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl, isHidden } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto("/login");
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("dashboard page loads successfully", async ({ page }) => {
|
||||
const { getText } = getSelectors(page);
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
await expect(getText("Agent dashboard")).toBeVisible();
|
||||
await expect(getText("Submit a New Agent")).toBeVisible();
|
||||
await expect(getText("Your uploaded agents")).toBeVisible();
|
||||
});
|
||||
|
||||
test("submit agent button works correctly", async ({ page }) => {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
|
||||
await page.goto("/profile/dashboard");
|
||||
const submitAgentButton = getId("submit-agent-button");
|
||||
await expect(submitAgentButton).toBeVisible();
|
||||
await submitAgentButton.click();
|
||||
|
||||
await expect(getText("Publish Agent")).toBeVisible();
|
||||
await expect(
|
||||
getText("Select your project that you'd like to publish"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('button[aria-label="Close"]').click();
|
||||
await expect(getText("Publish Agent")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("agent table view action works correctly for rejected agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
const agentTable = page.getByTestId("agent-table");
|
||||
await expect(agentTable).toBeVisible();
|
||||
|
||||
const rows = agentTable.getByTestId("agent-table-row");
|
||||
|
||||
// Find a row with rejected status
|
||||
const rejectedRow = rows.filter({ hasText: "Rejected" }).first();
|
||||
if (!(await rejectedRow.count())) {
|
||||
console.log("No rejected agents available; skipping view test.");
|
||||
return;
|
||||
}
|
||||
|
||||
await rejectedRow.scrollIntoViewIfNeeded();
|
||||
|
||||
const actionsButton = rejectedRow.getByTestId("agent-table-row-actions");
|
||||
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await actionsButton.scrollIntoViewIfNeeded();
|
||||
await actionsButton.click();
|
||||
|
||||
// View button testing
|
||||
const viewButton = page.getByRole("menuitem", { name: "View" });
|
||||
await expect(viewButton).toBeVisible();
|
||||
await viewButton.click();
|
||||
|
||||
const modal = page.getByTestId("publish-agent-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
const viewAgentName = modal.getByText("Agent is awaiting review");
|
||||
await expect(viewAgentName).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("agent table delete action works correctly", async ({ page }) => {
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
const agentTable = page.getByTestId("agent-table");
|
||||
await expect(agentTable).toBeVisible();
|
||||
|
||||
const rows = agentTable.getByTestId("agent-table-row");
|
||||
|
||||
// Delete button testing — only works for PENDING submissions
|
||||
const beforeCount = await rows.count();
|
||||
|
||||
if (beforeCount === 0) {
|
||||
console.log("No agents available; skipping delete flow.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a PENDING submission to delete
|
||||
const pendingRow = rows.filter({ hasText: "Pending" }).first();
|
||||
if (!(await pendingRow.count())) {
|
||||
console.log("No pending agents available; skipping delete flow.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedSubmissionId =
|
||||
await pendingRow.getAttribute("data-submission-id");
|
||||
await pendingRow.scrollIntoViewIfNeeded();
|
||||
|
||||
const delActionsButton = pendingRow.getByTestId("agent-table-row-actions");
|
||||
await delActionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await delActionsButton.scrollIntoViewIfNeeded();
|
||||
await delActionsButton.click();
|
||||
|
||||
const deleteButton = page.getByRole("menuitem", { name: "Delete" });
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await deleteButton.click();
|
||||
|
||||
// Assert that the card with the deleted agent ID is not visible
|
||||
await isHidden(page.locator(`[data-submission-id="${deletedSubmissionId}"]`));
|
||||
});
|
||||
|
||||
test("edit and delete actions are unavailable for non-pending submissions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
const agentTable = page.getByTestId("agent-table");
|
||||
await expect(agentTable).toBeVisible();
|
||||
|
||||
const rows = agentTable.getByTestId("agent-table-row");
|
||||
|
||||
// Test with rejected submissions (view only)
|
||||
const rejectedRow = rows.filter({ hasText: "Rejected" }).first();
|
||||
if (await rejectedRow.count()) {
|
||||
await rejectedRow.scrollIntoViewIfNeeded();
|
||||
const actionsButton = rejectedRow.getByTestId("agent-table-row-actions");
|
||||
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await actionsButton.scrollIntoViewIfNeeded();
|
||||
await actionsButton.click();
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "View" })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Edit" })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Delete" })).toHaveCount(0);
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
// Test with approved submissions (view only)
|
||||
const approvedRow = rows.filter({ hasText: "Approved" }).first();
|
||||
if (await approvedRow.count()) {
|
||||
await approvedRow.scrollIntoViewIfNeeded();
|
||||
const actionsButton = approvedRow.getByTestId("agent-table-row-actions");
|
||||
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await actionsButton.scrollIntoViewIfNeeded();
|
||||
await actionsButton.click();
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "View" })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Edit" })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Delete" })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("editing a pending submission works correctly", async ({ page }) => {
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
const agentTable = page.getByTestId("agent-table");
|
||||
await expect(agentTable).toBeVisible();
|
||||
|
||||
const rows = agentTable.getByTestId("agent-table-row");
|
||||
|
||||
// Find a PENDING submission to edit (only PENDING submissions can be edited)
|
||||
const pendingRow = rows.filter({ hasText: "Pending" }).first();
|
||||
if (!(await pendingRow.count())) {
|
||||
console.log("No pending agents available; skipping edit test.");
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeCount = await rows.count();
|
||||
|
||||
await pendingRow.scrollIntoViewIfNeeded();
|
||||
const actionsButton = pendingRow.getByTestId("agent-table-row-actions");
|
||||
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await actionsButton.scrollIntoViewIfNeeded();
|
||||
await actionsButton.click();
|
||||
|
||||
const editButton = page.getByRole("menuitem", { name: "Edit" });
|
||||
await expect(editButton).toBeVisible();
|
||||
await editButton.click();
|
||||
|
||||
const editModal = page.getByTestId("edit-agent-modal");
|
||||
await expect(editModal).toBeVisible();
|
||||
|
||||
const newTitle = `E2E Edit Pending ${Date.now()}`;
|
||||
await page.getByRole("textbox", { name: "Title" }).fill(newTitle);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Changes Summary" })
|
||||
.fill("E2E change - updating pending submission");
|
||||
|
||||
await page.getByRole("button", { name: "Update submission" }).click();
|
||||
await expect(editModal).not.toBeVisible();
|
||||
|
||||
// A new submission should appear with pending state
|
||||
await expect(async () => {
|
||||
const afterCount = await rows.count();
|
||||
expect(afterCount).toBeGreaterThan(beforeCount);
|
||||
}).toPass();
|
||||
|
||||
const newRow = rows.filter({ hasText: newTitle }).first();
|
||||
await expect(newRow).toBeVisible();
|
||||
await expect(newRow).toContainText(/Awaiting review/);
|
||||
});
|
||||
|
||||
test("editing a pending agent updates the same submission in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile/dashboard");
|
||||
|
||||
const agentTable = page.getByTestId("agent-table");
|
||||
await expect(agentTable).toBeVisible();
|
||||
|
||||
const rows = agentTable.getByTestId("agent-table-row");
|
||||
|
||||
const pendingRow = rows.filter({ hasText: /Awaiting review/ }).first();
|
||||
if (!(await pendingRow.count())) {
|
||||
console.log("No pending agents available; skipping pending edit test.");
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeCount = await rows.count();
|
||||
|
||||
await pendingRow.scrollIntoViewIfNeeded();
|
||||
const actionsButton = pendingRow.getByTestId("agent-table-row-actions");
|
||||
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await actionsButton.scrollIntoViewIfNeeded();
|
||||
await actionsButton.click();
|
||||
|
||||
const editButton = page.getByRole("menuitem", { name: "Edit" });
|
||||
await expect(editButton).toBeVisible();
|
||||
await editButton.click();
|
||||
|
||||
const editModal = page.getByTestId("edit-agent-modal");
|
||||
await expect(editModal).toBeVisible();
|
||||
|
||||
const newTitle = `E2E Edit Pending ${Date.now()}`;
|
||||
await page.getByRole("textbox", { name: "Title" }).fill(newTitle);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Changes Summary" })
|
||||
.fill("E2E change - pending -> same submission");
|
||||
|
||||
await page.getByRole("button", { name: "Update submission" }).click();
|
||||
await expect(editModal).not.toBeVisible();
|
||||
|
||||
// Count should remain the same
|
||||
await expect(async () => {
|
||||
const afterCount = await rows.count();
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
}).toPass();
|
||||
|
||||
const updatedRow = rows.filter({ hasText: newTitle }).first();
|
||||
await expect(updatedRow).toBeVisible();
|
||||
await expect(updatedRow).toContainText(/Awaiting review/);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.describe("API Keys Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto("/login");
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("should redirect to login page when user is not authenticated", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/profile/api-keys");
|
||||
await hasUrl(page, "/login?next=%2Fprofile%2Fapi-keys");
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create a new API key successfully", async ({ page }) => {
|
||||
const { getButton, getField } = getSelectors(page);
|
||||
await page.goto("/profile/api-keys");
|
||||
await getButton("Create Key").click();
|
||||
|
||||
await getField("Name").fill("Test Key");
|
||||
await getButton("Create").click();
|
||||
|
||||
await expect(
|
||||
page.getByText("AutoGPT Platform API Key Created"),
|
||||
).toBeVisible();
|
||||
await getButton("Close").first().click();
|
||||
|
||||
await expect(page.getByText("Test Key").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should revoke an existing API key", async ({ page }) => {
|
||||
const { getRole, getId } = getSelectors(page);
|
||||
await page.goto("/profile/api-keys");
|
||||
|
||||
const apiKeyRow = getId("api-key-row").first();
|
||||
const apiKeyContent = await apiKeyRow
|
||||
.getByTestId("api-key-id")
|
||||
.textContent();
|
||||
const apiKeyActions = apiKeyRow.getByTestId("api-key-actions").first();
|
||||
|
||||
await apiKeyActions.click();
|
||||
await getRole("menuitem", "Revoke").click();
|
||||
await expect(
|
||||
page.getByText("AutoGPT Platform API key revoked successfully"),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(apiKeyContent!)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
|
||||
test.describe("Builder", () => {
|
||||
let buildPage: BuildPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const loginPage = new LoginPage(page);
|
||||
const testUser = await getTestUser();
|
||||
|
||||
buildPage = new BuildPage(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/build");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await buildPage.closeTutorial();
|
||||
});
|
||||
|
||||
// --- Core tests ---
|
||||
|
||||
test("build page loads successfully", async () => {
|
||||
await expect(buildPage.isLoaded()).resolves.toBeTruthy();
|
||||
await expect(
|
||||
buildPage.getPlaywrightPage().getByTestId("blocks-control-blocks-button"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
buildPage.getPlaywrightPage().getByTestId("save-control-save-button"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("user can add a block via block menu", async () => {
|
||||
const initialCount = await buildPage.getNodeCount();
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(initialCount + 1);
|
||||
expect(await buildPage.getNodeCount()).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
test("user can add multiple blocks", async () => {
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(2);
|
||||
|
||||
expect(await buildPage.getNodeCount()).toBe(2);
|
||||
});
|
||||
|
||||
test("user can remove a block", async () => {
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
// Deselect, then re-select the node and delete
|
||||
await buildPage.clickCanvas();
|
||||
await buildPage.selectNode(0);
|
||||
await buildPage.deleteSelectedNodes();
|
||||
|
||||
await expect(buildPage.getNodeLocator()).toHaveCount(0, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test("user can save an agent", async ({ page }) => {
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
await buildPage.saveAgent("E2E Test Agent", "Created by e2e test");
|
||||
await buildPage.waitForSaveComplete();
|
||||
|
||||
expect(page.url()).toContain("flowID=");
|
||||
});
|
||||
|
||||
test("user can save and run button becomes enabled", async () => {
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
await buildPage.saveAgent("Runnable Agent", "Test run button");
|
||||
await buildPage.waitForSaveComplete();
|
||||
await buildPage.waitForSaveButton();
|
||||
|
||||
await expect(buildPage.isRunButtonEnabled()).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
// --- Copy / Paste test ---
|
||||
|
||||
test("user can copy and paste a node", async ({ context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
await buildPage.selectNode(0);
|
||||
await buildPage.copyViaKeyboard();
|
||||
await buildPage.pasteViaKeyboard();
|
||||
|
||||
await buildPage.waitForNodeOnCanvas(2);
|
||||
expect(await buildPage.getNodeCount()).toBe(2);
|
||||
});
|
||||
|
||||
// --- Run agent test ---
|
||||
|
||||
test("user can run an agent from the builder", async () => {
|
||||
await buildPage.addBlockByClick("Store Value");
|
||||
await buildPage.waitForNodeOnCanvas(1);
|
||||
|
||||
// Save the agent (required before running)
|
||||
await buildPage.saveAgent("Run Test Agent", "Testing run from builder");
|
||||
await buildPage.waitForSaveComplete();
|
||||
await buildPage.waitForSaveButton();
|
||||
|
||||
// Click run button
|
||||
await buildPage.clickRunButton();
|
||||
|
||||
// Either the run dialog appears or the agent starts running directly
|
||||
const runDialogOrRunning = await Promise.race([
|
||||
buildPage
|
||||
.getPlaywrightPage()
|
||||
.locator('[data-id="run-input-dialog-content"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 })
|
||||
.then(() => "dialog"),
|
||||
buildPage
|
||||
.getPlaywrightPage()
|
||||
.locator('[data-id="stop-graph-button"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 })
|
||||
.then(() => "running"),
|
||||
]).catch(() => "timeout");
|
||||
|
||||
expect(["dialog", "running"]).toContain(runDialogOrRunning);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
// E2E Test Credentials and Constants
|
||||
export const TEST_CREDENTIALS = {
|
||||
email: "test123@gmail.com",
|
||||
password: "testpassword123",
|
||||
} as const;
|
||||
|
||||
export function getTestUserWithLibraryAgents() {
|
||||
return TEST_CREDENTIALS;
|
||||
}
|
||||
|
||||
// Dummy constant to help developers identify agents that don't need input
|
||||
export const DummyInput = "DummyInput";
|
||||
|
||||
// This will be used for testing agent submission for test123@gmail.com
|
||||
export const TEST_AGENT_DATA = {
|
||||
name: "Test Agent Submission",
|
||||
description:
|
||||
"This is a test agent submission specifically created for frontend testing purposes.",
|
||||
image_urls: [
|
||||
"https://picsum.photos/200/300",
|
||||
"https://picsum.photos/200/301",
|
||||
"https://picsum.photos/200/302",
|
||||
],
|
||||
video_url: "https://www.youtube.com/watch?v=test123",
|
||||
sub_heading: "A test agent for frontend testing",
|
||||
categories: ["test", "demo", "frontend"],
|
||||
changes_summary: "Initial test submission",
|
||||
} as const;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { FullConfig } from "@playwright/test";
|
||||
import { createTestUsers, saveUserPool, loadUserPool } from "./utils/auth";
|
||||
|
||||
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) + 8; // workers + buffer
|
||||
console.log(`👥 Creating ${numberOfUsers} test users via signup...`);
|
||||
console.log("⏳ Note: This may take a few minutes in CI environments");
|
||||
|
||||
const users = await createTestUsers(numberOfUsers);
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new Error("Failed to create any test users");
|
||||
}
|
||||
|
||||
// Require at least a minimum number of users for tests to work
|
||||
const minUsers = Math.max(config.workers || 1, 2);
|
||||
if (users.length < minUsers) {
|
||||
throw new Error(
|
||||
`Only created ${users.length} users but need at least ${minUsers} for tests to run properly`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
console.error("💡 This is likely due to:");
|
||||
console.error(" 1. Backend services not fully ready");
|
||||
console.error(" 2. Network timeouts in CI environment");
|
||||
console.error(" 3. Database or authentication issues");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -2,11 +2,15 @@ import { beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { mockNextjsModules } from "./setup-nextjs-mocks";
|
||||
import { mockSupabaseRequest } from "./mock-supabase-request";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
beforeAll(() => {
|
||||
mockNextjsModules();
|
||||
mockSupabaseRequest(); // If you need user's data - please mock supabase actions in your specific test - it sends null user [It's only to avoid cookies() call]
|
||||
return server.listen({ onUnhandledRequest: "error" });
|
||||
});
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import path from "path";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LibraryPage } from "./pages/library.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.describe("Library", () => {
|
||||
let libraryPage: LibraryPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
libraryPage = new LibraryPage(page);
|
||||
|
||||
await page.goto("/login");
|
||||
const loginPage = new LoginPage(page);
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("library page loads successfully", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
await page.goto("/library");
|
||||
|
||||
await expect(getId("search-bar").first()).toBeVisible();
|
||||
await expect(getId("import-button").first()).toBeVisible();
|
||||
await expect(getId("sort-by-dropdown").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents are visible and cards work correctly", async ({ page }) => {
|
||||
await page.goto("/library");
|
||||
|
||||
const agents = await libraryPage.getAgents();
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
|
||||
const firstAgent = agents[0];
|
||||
expect(firstAgent).toBeTruthy();
|
||||
|
||||
await libraryPage.clickAgent(firstAgent);
|
||||
await hasUrl(page, `/library/agents/${firstAgent.id}`);
|
||||
|
||||
await libraryPage.navigateToLibrary();
|
||||
|
||||
const updatedAgents = await libraryPage.getAgents();
|
||||
const agentWithBuilder = updatedAgents.find((agent) =>
|
||||
agent.openInBuilderUrl.includes("/build"),
|
||||
);
|
||||
|
||||
if (agentWithBuilder) {
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent("page"),
|
||||
libraryPage.clickOpenInBuilder(agentWithBuilder),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
test.expect(newPage.url()).toContain(`/build`);
|
||||
await newPage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("pagination works correctly", async ({ page }, testInfo) => {
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
await page.goto("/library");
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const paginationResult = await libraryPage.testPagination();
|
||||
|
||||
if (paginationResult.initialCount >= PAGE_SIZE) {
|
||||
expect(paginationResult.finalCount).toBeGreaterThanOrEqual(
|
||||
paginationResult.initialCount,
|
||||
);
|
||||
expect(paginationResult.hasMore).toBeTruthy();
|
||||
}
|
||||
|
||||
await libraryPage.isPaginationWorking();
|
||||
|
||||
const allAgents = await libraryPage.getAgentsWithPagination();
|
||||
test.expect(allAgents.length).toBeGreaterThan(0);
|
||||
|
||||
const displayedCount = await libraryPage.getAgentCount();
|
||||
test.expect(allAgents.length).toEqual(displayedCount);
|
||||
});
|
||||
|
||||
test("searching works correctly", async ({ page }) => {
|
||||
await page.goto("/library");
|
||||
|
||||
const allAgents = await libraryPage.getAgents();
|
||||
expect(allAgents.length).toBeGreaterThan(0);
|
||||
|
||||
const initialAgentCount = await libraryPage.getAgentCount();
|
||||
expect(initialAgentCount).toBeGreaterThan(0);
|
||||
|
||||
const firstAgent = allAgents[0];
|
||||
await libraryPage.searchAgents(firstAgent.name);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const searchResults = await libraryPage.getAgents();
|
||||
expect(searchResults.length).toBeGreaterThan(0);
|
||||
|
||||
const foundAgent = searchResults.find(
|
||||
(agent) => agent.name === firstAgent.name,
|
||||
);
|
||||
expect(foundAgent).toBeTruthy();
|
||||
|
||||
const searchValue = await libraryPage.getSearchValue();
|
||||
expect(searchValue).toBe(firstAgent.name);
|
||||
|
||||
const partialSearchTerm = firstAgent.name.substring(0, 3);
|
||||
await libraryPage.searchAgents(partialSearchTerm);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const partialSearchResults = await libraryPage.getAgents();
|
||||
expect(partialSearchResults.length).toBeGreaterThan(0);
|
||||
|
||||
const matchingAgents = partialSearchResults.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(partialSearchTerm.toLowerCase()),
|
||||
);
|
||||
expect(matchingAgents.length).toBeGreaterThan(0);
|
||||
|
||||
await libraryPage.searchAgents("nonexistentagentnamethatdoesnotexist");
|
||||
const noResults = await libraryPage.getAgentCount();
|
||||
expect(noResults).toBe(0);
|
||||
|
||||
const hasNoAgentsMessage = await libraryPage.hasNoAgentsMessage();
|
||||
expect(hasNoAgentsMessage).toBeTruthy();
|
||||
|
||||
await libraryPage.clearSearch();
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const clearedSearchCount = await libraryPage.getAgentCount();
|
||||
test.expect(clearedSearchCount).toEqual(initialAgentCount);
|
||||
|
||||
const clearedSearchValue = await libraryPage.getSearchValue();
|
||||
test.expect(clearedSearchValue).toBe("");
|
||||
});
|
||||
|
||||
test("pagination while searching works correctly", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
await page.goto("/library");
|
||||
|
||||
const allAgents = await libraryPage.getAgents();
|
||||
test.expect(allAgents.length).toBeGreaterThan(0);
|
||||
|
||||
const searchTerm = "Agent";
|
||||
|
||||
await libraryPage.searchAgents(searchTerm);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const initialSearchResults = await libraryPage.getAgents();
|
||||
expect(initialSearchResults.length).toBeGreaterThan(0);
|
||||
|
||||
const matchingResults = initialSearchResults.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
expect(matchingResults.length).toEqual(initialSearchResults.length);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const searchPaginationResult = await libraryPage.testPagination();
|
||||
|
||||
if (searchPaginationResult.initialCount >= PAGE_SIZE) {
|
||||
expect(searchPaginationResult.finalCount).toBeGreaterThanOrEqual(
|
||||
searchPaginationResult.initialCount,
|
||||
);
|
||||
|
||||
const allPaginatedResults = await libraryPage.getAgentsWithPagination();
|
||||
const matchingPaginatedResults = allPaginatedResults.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
expect(matchingPaginatedResults.length).toEqual(
|
||||
allPaginatedResults.length,
|
||||
);
|
||||
}
|
||||
|
||||
await libraryPage.scrollAndWaitForNewAgents();
|
||||
|
||||
const finalSearchResults = await libraryPage.getAgents();
|
||||
const finalMatchingResults = finalSearchResults.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
expect(finalMatchingResults.length).toEqual(finalSearchResults.length);
|
||||
|
||||
const preservedSearchValue = await libraryPage.getSearchValue();
|
||||
expect(preservedSearchValue).toBe(searchTerm);
|
||||
|
||||
await libraryPage.clearSearch();
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const clearedResults = await libraryPage.getAgents();
|
||||
expect(clearedResults.length).toBeGreaterThanOrEqual(
|
||||
initialSearchResults.length,
|
||||
);
|
||||
});
|
||||
|
||||
test("uploading an agent works correctly", async ({ page }) => {
|
||||
await page.goto("/library");
|
||||
|
||||
await libraryPage.openUploadDialog();
|
||||
|
||||
expect(await libraryPage.isUploadDialogVisible()).toBeTruthy();
|
||||
expect(await libraryPage.isUploadButtonEnabled()).toBeFalsy();
|
||||
|
||||
const testAgentName = "Test Upload Agent";
|
||||
const testAgentDescription = "This is a test agent uploaded via automation";
|
||||
await libraryPage.fillUploadForm(testAgentName, testAgentDescription);
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const testAgentPath = path.resolve(
|
||||
__dirname,
|
||||
"assets",
|
||||
"testing_agent.json",
|
||||
);
|
||||
await fileInput.setInputFiles(testAgentPath);
|
||||
|
||||
// Wait for file to be processed and upload button to be enabled
|
||||
const uploadButton = page.getByRole("button", { name: "Upload" });
|
||||
await uploadButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await expect(uploadButton).toBeEnabled({ timeout: 10000 });
|
||||
|
||||
expect(await libraryPage.isUploadButtonEnabled()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Upload" }).click();
|
||||
|
||||
await page.waitForURL("**/build**", { timeout: 10000 });
|
||||
expect(page.url()).toContain("/build");
|
||||
|
||||
await page.goto("/library");
|
||||
|
||||
await libraryPage.searchAgents(testAgentName);
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const searchResults = await libraryPage.getAgents();
|
||||
test.expect(searchResults.length).toBeGreaterThan(0);
|
||||
|
||||
const uploadedAgent = searchResults.find((agent) =>
|
||||
agent.name.includes(testAgentName),
|
||||
);
|
||||
test.expect(uploadedAgent).toBeTruthy();
|
||||
|
||||
if (uploadedAgent) {
|
||||
test.expect(uploadedAgent.name).toContain(testAgentName);
|
||||
test.expect(uploadedAgent.seeRunsUrl).toBeTruthy();
|
||||
test.expect(uploadedAgent.openInBuilderUrl).toBeTruthy();
|
||||
}
|
||||
|
||||
await libraryPage.clearSearch();
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { MarketplacePage } from "./pages/marketplace.page";
|
||||
import { hasUrl, isVisible, matchesUrl } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test.describe("Marketplace Agent Page - Basic Functionality", () => {
|
||||
test("User can access agent page when logged out", async ({ page }) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstStoreCard = await marketplacePage.getFirstTopAgent();
|
||||
await firstStoreCard.click();
|
||||
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
await matchesUrl(page, /\/marketplace\/agent\/.+/);
|
||||
});
|
||||
|
||||
test("User can access agent page when logged in", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstStoreCard = await marketplacePage.getFirstTopAgent();
|
||||
await firstStoreCard.click();
|
||||
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
await matchesUrl(page, /\/marketplace\/agent\/.+/);
|
||||
});
|
||||
|
||||
test("Agent page details are visible", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstStoreCard = await marketplacePage.getFirstTopAgent();
|
||||
await firstStoreCard.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
|
||||
const agentTitle = getId("agent-title");
|
||||
await isVisible(agentTitle);
|
||||
|
||||
const agentDescription = getId("agent-description");
|
||||
await isVisible(agentDescription);
|
||||
|
||||
const creatorInfo = getId("agent-creator");
|
||||
await isVisible(creatorInfo);
|
||||
});
|
||||
|
||||
test("Download button functionality works", async ({ page }) => {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstStoreCard = await marketplacePage.getFirstTopAgent();
|
||||
await firstStoreCard.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
|
||||
const downloadButton = getId("agent-download-button");
|
||||
await isVisible(downloadButton);
|
||||
await downloadButton.click();
|
||||
|
||||
const downloadSuccessMessage = getText(
|
||||
"Your agent has been successfully downloaded.",
|
||||
);
|
||||
await isVisible(downloadSuccessMessage);
|
||||
});
|
||||
|
||||
test("Add to library button works and agent appears in library", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstStoreCard = await marketplacePage.getFirstTopAgent();
|
||||
await firstStoreCard.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
|
||||
const agentTitle = await getId("agent-title").textContent();
|
||||
if (!agentTitle || !agentTitle.trim()) {
|
||||
throw new Error("Agent title not found on marketplace agent page");
|
||||
}
|
||||
const agentName = agentTitle.trim();
|
||||
|
||||
const addToLibraryButton = getId("agent-add-library-button");
|
||||
await isVisible(addToLibraryButton);
|
||||
await addToLibraryButton.click();
|
||||
|
||||
const addSuccessMessage = getText("Redirecting to your library...");
|
||||
await isVisible(addSuccessMessage);
|
||||
|
||||
await page.waitForURL("**/library/agents/**");
|
||||
await expect(page).toHaveTitle(
|
||||
new RegExp(`${escapeRegExp(agentName)} - Library - AutoGPT Platform`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { test } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { MarketplacePage } from "./pages/marketplace.page";
|
||||
import { hasUrl, isVisible, matchesUrl } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.describe("Marketplace Creator Page – Basic Functionality", () => {
|
||||
test("User can access creator's page when logged out", async ({ page }) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstCreatorProfile =
|
||||
await marketplacePage.getFirstCreatorProfile(page);
|
||||
await firstCreatorProfile.click();
|
||||
|
||||
await page.waitForURL("**/marketplace/creator/**");
|
||||
await matchesUrl(page, /\/marketplace\/creator\/.+/);
|
||||
});
|
||||
|
||||
test("User can access creator's page when logged in", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstCreatorProfile =
|
||||
await marketplacePage.getFirstCreatorProfile(page);
|
||||
await firstCreatorProfile.click();
|
||||
|
||||
await page.waitForURL("**/marketplace/creator/**");
|
||||
await matchesUrl(page, /\/marketplace\/creator\/.+/);
|
||||
});
|
||||
|
||||
test("Creator page details are visible", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstCreatorProfile =
|
||||
await marketplacePage.getFirstCreatorProfile(page);
|
||||
await firstCreatorProfile.click();
|
||||
await page.waitForURL("**/marketplace/creator/**");
|
||||
|
||||
const creatorTitle = getId("creator-title");
|
||||
await isVisible(creatorTitle);
|
||||
|
||||
const creatorDescription = getId("creator-description");
|
||||
await isVisible(creatorDescription);
|
||||
});
|
||||
|
||||
test("Agents in agent by sections navigation works", async ({ page }) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const firstCreatorProfile =
|
||||
await marketplacePage.getFirstCreatorProfile(page);
|
||||
await firstCreatorProfile.click();
|
||||
await page.waitForURL("**/marketplace/creator/**");
|
||||
|
||||
const firstAgent = page
|
||||
.locator('[data-testid="store-card"]:visible')
|
||||
.first();
|
||||
await firstAgent.waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
await firstAgent.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
await matchesUrl(page, /\/marketplace\/agent\/.+/);
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { expect, test } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { MarketplacePage } from "./pages/marketplace.page";
|
||||
import { hasMinCount, hasUrl, isVisible, matchesUrl } from "./utils/assertion";
|
||||
|
||||
// Marketplace tests for store agent search functionality
|
||||
test.describe("Marketplace – Basic Functionality", () => {
|
||||
test("User can access marketplace page when logged out", async ({ page }) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const marketplaceTitle = await marketplacePage.getMarketplaceTitle(page);
|
||||
await isVisible(marketplaceTitle);
|
||||
|
||||
console.log(
|
||||
"User can access marketplace page when logged out test passed ✅",
|
||||
);
|
||||
});
|
||||
|
||||
test("User can access marketplace page when logged in", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await marketplacePage.goto(page);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const marketplaceTitle = await marketplacePage.getMarketplaceTitle(page);
|
||||
await isVisible(marketplaceTitle);
|
||||
|
||||
console.log(
|
||||
"User can access marketplace page when logged in test passed ✅",
|
||||
);
|
||||
});
|
||||
|
||||
test("Featured agents, top agents, and featured creators are visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const featuredAgentsSection =
|
||||
await marketplacePage.getFeaturedAgentsSection(page);
|
||||
await isVisible(featuredAgentsSection);
|
||||
const featuredAgentCards =
|
||||
await marketplacePage.getFeaturedAgentCards(page);
|
||||
await hasMinCount(featuredAgentCards, 1);
|
||||
|
||||
const topAgentsSection = await marketplacePage.getTopAgentsSection(page);
|
||||
await isVisible(topAgentsSection);
|
||||
const topAgentCards = await marketplacePage.getTopAgentCards(page);
|
||||
await hasMinCount(topAgentCards, 1);
|
||||
|
||||
const featuredCreatorsSection =
|
||||
await marketplacePage.getFeaturedCreatorsSection(page);
|
||||
await isVisible(featuredCreatorsSection);
|
||||
const creatorProfiles = await marketplacePage.getCreatorProfiles(page);
|
||||
await hasMinCount(creatorProfiles, 1);
|
||||
|
||||
console.log(
|
||||
"Featured agents, top agents, and featured creators are visible test passed ✅",
|
||||
);
|
||||
});
|
||||
|
||||
test("Can navigate and interact with marketplace elements", async ({
|
||||
page,
|
||||
}) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstFeaturedAgent =
|
||||
await marketplacePage.getFirstFeaturedAgent(page);
|
||||
await firstFeaturedAgent.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
await matchesUrl(page, /\/marketplace\/agent\/.+/);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstTopAgent = await marketplacePage.getFirstTopAgent();
|
||||
await firstTopAgent.click();
|
||||
await page.waitForURL("**/marketplace/agent/**");
|
||||
await matchesUrl(page, /\/marketplace\/agent\/.+/);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
const firstCreatorProfile =
|
||||
await marketplacePage.getFirstCreatorProfile(page);
|
||||
await firstCreatorProfile.click();
|
||||
await page.waitForURL("**/marketplace/creator/**");
|
||||
await matchesUrl(page, /\/marketplace\/creator\/.+/);
|
||||
|
||||
console.log(
|
||||
"Can navigate and interact with marketplace elements test passed ✅",
|
||||
);
|
||||
});
|
||||
|
||||
test("Complete search flow works correctly", async ({ page }) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
await marketplacePage.searchAndNavigate("DummyInput", page);
|
||||
|
||||
await marketplacePage.waitForSearchResults();
|
||||
|
||||
await matchesUrl(page, /\/marketplace\/search\?searchTerm=/);
|
||||
|
||||
const resultsHeading = page.getByText("Results for:");
|
||||
await isVisible(resultsHeading);
|
||||
|
||||
const searchTerm = page.getByText("DummyInput").first();
|
||||
await isVisible(searchTerm);
|
||||
|
||||
await expect
|
||||
.poll(() => marketplacePage.getSearchResultsCount(page), {
|
||||
timeout: 15000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
console.log("Complete search flow works correctly test passed ✅");
|
||||
});
|
||||
|
||||
// We need to add a test search with filters, but the current business logic for filters doesn't work as expected. We'll add it once we modify that.
|
||||
});
|
||||
|
||||
test.describe("Marketplace – Edge Cases", () => {
|
||||
test("Search for non-existent item renders search page correctly", async ({
|
||||
page,
|
||||
}) => {
|
||||
const marketplacePage = new MarketplacePage(page);
|
||||
await marketplacePage.goto(page);
|
||||
|
||||
await marketplacePage.searchAndNavigate("xyznonexistentitemxyz123", page);
|
||||
|
||||
await marketplacePage.waitForSearchResults();
|
||||
|
||||
await matchesUrl(page, /\/marketplace\/search\?searchTerm=/);
|
||||
|
||||
const resultsHeading = page.getByText("Results for:");
|
||||
await isVisible(resultsHeading);
|
||||
|
||||
const searchTerm = page.getByText("xyznonexistentitemxyz123");
|
||||
await isVisible(searchTerm);
|
||||
|
||||
// The search page should render either results or a "No results found" message
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const hasResults =
|
||||
(await page.locator('[data-testid="store-card"]').count()) > 0;
|
||||
const hasNoResultsMsg = await page
|
||||
.getByText("No results found")
|
||||
.isVisible();
|
||||
return hasResults || hasNoResultsMsg;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
console.log(
|
||||
"Search for non-existent item renders search page correctly test passed ✅",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { signupTestUser } from "./utils/signup";
|
||||
import { completeOnboardingWizard } from "./utils/onboarding";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test("new user completes full onboarding wizard", async ({ page }) => {
|
||||
// Signup WITHOUT skipping onboarding (ignoreOnboarding=false)
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
|
||||
// Should be on onboarding
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Complete the wizard
|
||||
await completeOnboardingWizard(page, {
|
||||
name: "Alice",
|
||||
role: "Marketing",
|
||||
painPoints: ["Social media", "Email & outreach"],
|
||||
});
|
||||
|
||||
// Should have been redirected to /copilot
|
||||
await expect(page).toHaveURL(/\/copilot/);
|
||||
|
||||
// User should be authenticated
|
||||
await page
|
||||
.getByTestId("profile-popout-menu-trigger")
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
});
|
||||
|
||||
test("onboarding wizard step navigation works", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: Welcome
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await page.getByLabel("What should I call you?").fill("Bob");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 2: Role — verify we're here, then go back
|
||||
await expect(page.getByText("What best describes you")).toBeVisible();
|
||||
await page.getByText("Back").click();
|
||||
|
||||
// Should be back on step 1 with name preserved
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await expect(page.getByLabel("What should I call you?")).toHaveValue("Bob");
|
||||
});
|
||||
|
||||
test("onboarding wizard validates required fields", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: Continue should be disabled without a name
|
||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
||||
await expect(continueButton).toBeDisabled();
|
||||
|
||||
// Fill name — continue should become enabled
|
||||
await page.getByLabel("What should I call you?").fill("Charlie");
|
||||
await expect(continueButton).toBeEnabled();
|
||||
await continueButton.click();
|
||||
|
||||
// Step 2: Role — selecting auto-advances to step 3
|
||||
await expect(page.getByText("What best describes you")).toBeVisible();
|
||||
await page.getByText("Engineering").click();
|
||||
|
||||
// Step 3: Launch Autopilot should be disabled without any pain points
|
||||
const launchButton = page.getByRole("button", { name: "Launch Autopilot" });
|
||||
await expect(launchButton).toBeDisabled();
|
||||
|
||||
// Select a pain point — button should become enabled
|
||||
await page.getByText("Research", { exact: true }).click();
|
||||
await expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("completed onboarding redirects away from /onboarding", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create user and complete onboarding
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await completeOnboardingWizard(page);
|
||||
|
||||
// Try to navigate back to onboarding — should be redirected to /copilot
|
||||
await page.goto("http://localhost:3000/onboarding");
|
||||
await page.waitForURL(/\/copilot/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("onboarding URL params sync with steps", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: URL may or may not include step=1 on initial load (no param is equivalent to step 1)
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
|
||||
// Fill name and go to step 2
|
||||
await page.getByLabel("What should I call you?").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// URL should show step=2
|
||||
await expect(page).toHaveURL(/step=2/);
|
||||
});
|
||||
|
||||
test("role-based pain point ordering works", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
|
||||
// Complete step 1
|
||||
await page.getByLabel("What should I call you?").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Select Sales/BD role (auto-advances to step 3)
|
||||
await page.getByText("Sales / BD").click();
|
||||
|
||||
// On pain points step, "Finding leads" should be visible (top pick for Sales)
|
||||
await expect(page.getByText("What's eating your time?")).toBeVisible();
|
||||
const { getText } = getSelectors(page);
|
||||
await expect(getText("Finding leads")).toBeVisible();
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export class BuildPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto("/build");
|
||||
await this.page.waitForLoadState("domcontentloaded");
|
||||
}
|
||||
|
||||
async isLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
|
||||
await this.page
|
||||
.locator(".react-flow")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async closeTutorial(): Promise<void> {
|
||||
try {
|
||||
await this.page
|
||||
.getByRole("button", { name: "Skip Tutorial", exact: true })
|
||||
.click({ timeout: 3000 });
|
||||
} catch {
|
||||
// Tutorial not shown or already dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block Menu ---
|
||||
|
||||
async openBlocksPanel(): Promise<void> {
|
||||
const popoverContent = this.page.locator(
|
||||
'[data-id="blocks-control-popover-content"]',
|
||||
);
|
||||
if (!(await popoverContent.isVisible())) {
|
||||
await this.page.getByTestId("blocks-control-blocks-button").click();
|
||||
await popoverContent.waitFor({ state: "visible", timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async closeBlocksPanel(): Promise<void> {
|
||||
const popoverContent = this.page.locator(
|
||||
'[data-id="blocks-control-popover-content"]',
|
||||
);
|
||||
if (await popoverContent.isVisible()) {
|
||||
await this.page.getByTestId("blocks-control-blocks-button").click();
|
||||
await popoverContent.waitFor({ state: "hidden", timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async searchBlock(searchTerm: string): Promise<void> {
|
||||
const searchInput = this.page.locator(
|
||||
'[data-id="blocks-control-search-bar"] input[type="text"]',
|
||||
);
|
||||
await searchInput.clear();
|
||||
await searchInput.fill(searchTerm);
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
private getBlockCardByName(name: string): Locator {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const exactName = new RegExp(`^\\s*${escapedName}\\s*$`, "i");
|
||||
return this.page
|
||||
.locator('[data-id^="block-card-"]')
|
||||
.filter({ has: this.page.locator("span", { hasText: exactName }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
async addBlockByClick(searchTerm: string): Promise<void> {
|
||||
await this.openBlocksPanel();
|
||||
await this.searchBlock(searchTerm);
|
||||
|
||||
// Wait for any search results to appear
|
||||
const anyCard = this.page.locator('[data-id^="block-card-"]').first();
|
||||
await anyCard.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Click the card matching the search term name
|
||||
const blockCard = this.getBlockCardByName(searchTerm);
|
||||
await blockCard.waitFor({ state: "visible", timeout: 5000 });
|
||||
await blockCard.click();
|
||||
|
||||
// Close the panel so it doesn't overlay the canvas
|
||||
await this.closeBlocksPanel();
|
||||
}
|
||||
|
||||
async dragBlockToCanvas(searchTerm: string): Promise<void> {
|
||||
await this.openBlocksPanel();
|
||||
await this.searchBlock(searchTerm);
|
||||
|
||||
const anyCard = this.page.locator('[data-id^="block-card-"]').first();
|
||||
await anyCard.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
const blockCard = this.getBlockCardByName(searchTerm);
|
||||
await blockCard.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
const canvas = this.page.locator(".react-flow__pane").first();
|
||||
await blockCard.dragTo(canvas);
|
||||
}
|
||||
|
||||
// --- Nodes on Canvas ---
|
||||
|
||||
getNodeLocator(index?: number): Locator {
|
||||
const locator = this.page.locator('[data-id^="custom-node-"]');
|
||||
return index !== undefined ? locator.nth(index) : locator;
|
||||
}
|
||||
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.getNodeLocator().count();
|
||||
}
|
||||
|
||||
async waitForNodeOnCanvas(expectedCount?: number): Promise<void> {
|
||||
if (expectedCount !== undefined) {
|
||||
await expect(this.getNodeLocator()).toHaveCount(expectedCount, {
|
||||
timeout: 10000,
|
||||
});
|
||||
} else {
|
||||
await this.getNodeLocator()
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
async selectNode(index: number = 0): Promise<void> {
|
||||
const node = this.getNodeLocator(index);
|
||||
await node.click();
|
||||
}
|
||||
|
||||
async selectAllNodes(): Promise<void> {
|
||||
await this.page.locator(".react-flow__pane").first().click();
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
}
|
||||
|
||||
async deleteSelectedNodes(): Promise<void> {
|
||||
await this.page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
// --- Connections (Edges) ---
|
||||
|
||||
async connectNodes(
|
||||
sourceNodeIndex: number,
|
||||
targetNodeIndex: number,
|
||||
): Promise<void> {
|
||||
// Get the node wrapper elements to scope handle search
|
||||
const sourceNode = this.getNodeLocator(sourceNodeIndex);
|
||||
const targetNode = this.getNodeLocator(targetNodeIndex);
|
||||
|
||||
// ReactFlow renders Handle components as .react-flow__handle elements
|
||||
// Output handles have class .react-flow__handle-right (Position.Right)
|
||||
// Input handles have class .react-flow__handle-left (Position.Left)
|
||||
const sourceHandle = sourceNode
|
||||
.locator(".react-flow__handle-right")
|
||||
.first();
|
||||
const targetHandle = targetNode.locator(".react-flow__handle-left").first();
|
||||
|
||||
// Get precise center coordinates using evaluate to avoid CSS transform issues
|
||||
const getHandleCenter = async (locator: Locator) => {
|
||||
const el = await locator.elementHandle();
|
||||
if (!el) throw new Error("Handle element not found");
|
||||
const rect = await el.evaluate((node) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
});
|
||||
return rect;
|
||||
};
|
||||
|
||||
const source = await getHandleCenter(sourceHandle);
|
||||
const target = await getHandleCenter(targetHandle);
|
||||
|
||||
// ReactFlow requires a proper drag sequence with intermediate moves
|
||||
await this.page.mouse.move(source.x, source.y);
|
||||
await this.page.mouse.down();
|
||||
// Move in steps to trigger ReactFlow's connection detection
|
||||
const steps = 20;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const ratio = i / steps;
|
||||
await this.page.mouse.move(
|
||||
source.x + (target.x - source.x) * ratio,
|
||||
source.y + (target.y - source.y) * ratio,
|
||||
);
|
||||
}
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
async getEdgeCount(): Promise<number> {
|
||||
return await this.page.locator(".react-flow__edge").count();
|
||||
}
|
||||
|
||||
// --- Save ---
|
||||
|
||||
async saveAgent(
|
||||
name: string = "Test Agent",
|
||||
description: string = "",
|
||||
): Promise<void> {
|
||||
await this.page.getByTestId("save-control-save-button").click();
|
||||
|
||||
const nameInput = this.page.getByTestId("save-control-name-input");
|
||||
await nameInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await nameInput.fill(name);
|
||||
|
||||
if (description) {
|
||||
await this.page
|
||||
.getByTestId("save-control-description-input")
|
||||
.fill(description);
|
||||
}
|
||||
|
||||
await this.page.getByTestId("save-control-save-agent-button").click();
|
||||
}
|
||||
|
||||
async waitForSaveComplete(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/flowID=/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async waitForSaveButton(): Promise<void> {
|
||||
await this.page.waitForSelector(
|
||||
'[data-testid="save-control-save-button"]:not([disabled])',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Run ---
|
||||
|
||||
async isRunButtonEnabled(): Promise<boolean> {
|
||||
const runButton = this.page.locator('[data-id="run-graph-button"]');
|
||||
return await runButton.isEnabled();
|
||||
}
|
||||
|
||||
async clickRunButton(): Promise<void> {
|
||||
const runButton = this.page.locator('[data-id="run-graph-button"]');
|
||||
await runButton.click();
|
||||
}
|
||||
|
||||
// --- Undo / Redo ---
|
||||
|
||||
async isUndoEnabled(): Promise<boolean> {
|
||||
const btn = this.page.locator('[data-id="undo-button"]');
|
||||
return !(await btn.isDisabled());
|
||||
}
|
||||
|
||||
async isRedoEnabled(): Promise<boolean> {
|
||||
const btn = this.page.locator('[data-id="redo-button"]');
|
||||
return !(await btn.isDisabled());
|
||||
}
|
||||
|
||||
async clickUndo(): Promise<void> {
|
||||
await this.page.locator('[data-id="undo-button"]').click();
|
||||
}
|
||||
|
||||
async clickRedo(): Promise<void> {
|
||||
await this.page.locator('[data-id="redo-button"]').click();
|
||||
}
|
||||
|
||||
// --- Copy / Paste ---
|
||||
|
||||
async copyViaKeyboard(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+c" : "Control+c");
|
||||
}
|
||||
|
||||
async pasteViaKeyboard(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
await this.page.keyboard.press(isMac ? "Meta+v" : "Control+v");
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async fillBlockInputByPlaceholder(
|
||||
placeholder: string,
|
||||
value: string,
|
||||
nodeIndex: number = 0,
|
||||
): Promise<void> {
|
||||
const node = this.getNodeLocator(nodeIndex);
|
||||
const input = node.getByPlaceholder(placeholder);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
async clickCanvas(): Promise<void> {
|
||||
const pane = this.page.locator(".react-flow__pane").first();
|
||||
const box = await pane.boundingBox();
|
||||
if (box) {
|
||||
// Click in the center of the canvas to avoid sidebar/toolbar overlaps
|
||||
await pane.click({
|
||||
position: { x: box.width / 2, y: box.height / 2 },
|
||||
});
|
||||
} else {
|
||||
await pane.click();
|
||||
}
|
||||
}
|
||||
|
||||
getPlaywrightPage(): Page {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
async createDummyAgent(): Promise<void> {
|
||||
await this.closeTutorial();
|
||||
await this.addBlockByClick("Add to Dictionary");
|
||||
await this.waitForNodeOnCanvas(1);
|
||||
await this.saveAgent("Test Agent", "Test Description");
|
||||
await this.waitForSaveComplete();
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
import { Locator, Page } from "@playwright/test";
|
||||
import { getSelectors } from "../utils/selectors";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
seeRunsUrl: string;
|
||||
openInBuilderUrl: string;
|
||||
}
|
||||
|
||||
export class LibraryPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async isLoaded(): Promise<boolean> {
|
||||
console.log(`checking if library page is loaded`);
|
||||
try {
|
||||
await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
|
||||
|
||||
await this.page.waitForSelector('[data-testid="library-textbox"]', {
|
||||
state: "visible",
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
console.log("Library page is loaded successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("Library page failed to load:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToLibrary(): Promise<void> {
|
||||
await this.page.goto("/library");
|
||||
await this.isLoaded();
|
||||
}
|
||||
|
||||
async getAgentCount(): Promise<number> {
|
||||
const { getId } = getSelectors(this.page);
|
||||
const countText = await getId("agents-count").textContent();
|
||||
const match = countText?.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
async getAgentCountByListLength(): Promise<number> {
|
||||
const { getId } = getSelectors(this.page);
|
||||
const agentCards = await getId("library-agent-card").all();
|
||||
return agentCards.length;
|
||||
}
|
||||
|
||||
async searchAgents(searchTerm: string): Promise<void> {
|
||||
console.log(`searching for agents with term: ${searchTerm}`);
|
||||
const { getRole } = getSelectors(this.page);
|
||||
const searchInput = getRole("textbox", "Search agents");
|
||||
await searchInput.fill(searchTerm);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async clearSearch(): Promise<void> {
|
||||
console.log(`clearing search`);
|
||||
try {
|
||||
// Look for the clear button (X icon)
|
||||
const clearButton = this.page.locator(".lucide.lucide-x");
|
||||
if (await clearButton.isVisible()) {
|
||||
await clearButton.click();
|
||||
} else {
|
||||
// If no clear button, clear the search input directly
|
||||
const searchInput = this.page.getByRole("textbox", {
|
||||
name: "Search agents",
|
||||
});
|
||||
await searchInput.fill("");
|
||||
}
|
||||
|
||||
// Wait for results to update
|
||||
await this.page.waitForTimeout(500);
|
||||
} catch (error) {
|
||||
console.error("Error clearing search:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async selectSortOption(
|
||||
page: Page,
|
||||
sortOption: "Creation Date" | "Last Modified",
|
||||
): Promise<void> {
|
||||
const { getRole } = getSelectors(page);
|
||||
await getRole("combobox").click();
|
||||
|
||||
await getRole("option", sortOption).click();
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async getCurrentSortOption(): Promise<string> {
|
||||
console.log(`getting current sort option`);
|
||||
try {
|
||||
const sortCombobox = this.page.getByRole("combobox");
|
||||
const currentOption = await sortCombobox.textContent();
|
||||
return currentOption?.trim() || "";
|
||||
} catch (error) {
|
||||
console.error("Error getting current sort option:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async openUploadDialog(): Promise<void> {
|
||||
console.log(`opening upload dialog`);
|
||||
// Open the unified Import dialog first
|
||||
await this.page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
// Wait for dialog to appear
|
||||
await this.page.getByRole("dialog", { name: "Import" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Click the "AutoGPT agent" tab
|
||||
await this.page.getByRole("tab", { name: "AutoGPT agent" }).click();
|
||||
}
|
||||
|
||||
async closeUploadDialog(): Promise<void> {
|
||||
await this.page.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
await this.page.getByRole("dialog", { name: "Import" }).waitFor({
|
||||
state: "hidden",
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
async isUploadDialogVisible(): Promise<boolean> {
|
||||
console.log(`checking if upload dialog is visible`);
|
||||
try {
|
||||
const dialog = this.page.getByRole("dialog", { name: "Import" });
|
||||
return await dialog.isVisible();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fillUploadForm(agentName: string, description: string): Promise<void> {
|
||||
console.log(
|
||||
`filling upload form with name: ${agentName}, description: ${description}`,
|
||||
);
|
||||
|
||||
// Fill agent name
|
||||
await this.page
|
||||
.getByRole("textbox", { name: "Agent name" })
|
||||
.fill(agentName);
|
||||
|
||||
// Fill description
|
||||
await this.page
|
||||
.getByRole("textbox", { name: "Agent description" })
|
||||
.fill(description);
|
||||
}
|
||||
|
||||
async isUploadButtonEnabled(): Promise<boolean> {
|
||||
console.log(`checking if upload button is enabled`);
|
||||
try {
|
||||
const uploadButton = this.page.getByRole("button", {
|
||||
name: "Upload",
|
||||
});
|
||||
return await uploadButton.isEnabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAgents(): Promise<Agent[]> {
|
||||
const { getId } = getSelectors(this.page);
|
||||
const agents: Agent[] = [];
|
||||
|
||||
await getId("library-agent-card")
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
const agentCards = await getId("library-agent-card").all();
|
||||
|
||||
for (const card of agentCards) {
|
||||
const name = await getId("library-agent-card-name", card).textContent();
|
||||
const seeRunsLink = getId("library-agent-card-see-runs-link", card);
|
||||
const openInBuilderLink = getId(
|
||||
"library-agent-card-open-in-builder-link",
|
||||
card,
|
||||
);
|
||||
|
||||
const seeRunsUrl = await seeRunsLink.getAttribute("href");
|
||||
|
||||
// Check if the "Open in builder" link exists before getting its href
|
||||
const openInBuilderLinkCount = await openInBuilderLink.count();
|
||||
const openInBuilderUrl =
|
||||
openInBuilderLinkCount > 0
|
||||
? await openInBuilderLink.getAttribute("href")
|
||||
: null;
|
||||
|
||||
if (name && seeRunsUrl) {
|
||||
const idMatch = seeRunsUrl.match(/\/library\/agents\/([^\/]+)/);
|
||||
const id = idMatch ? idMatch[1] : "";
|
||||
|
||||
agents.push({
|
||||
id,
|
||||
name: name.trim(),
|
||||
description: "", // Description is not currently rendered in the card
|
||||
seeRunsUrl,
|
||||
openInBuilderUrl: openInBuilderUrl || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`found ${agents.length} agents`);
|
||||
return agents;
|
||||
}
|
||||
|
||||
async clickAgent(agent: Agent): Promise<void> {
|
||||
const { getId } = getSelectors(this.page);
|
||||
const nameElement = getId("library-agent-card-name").filter({
|
||||
hasText: agent.name,
|
||||
});
|
||||
await nameElement.first().click();
|
||||
}
|
||||
|
||||
async clickSeeRuns(agent: Agent): Promise<void> {
|
||||
console.log(`clicking see runs for agent: ${agent.name}`);
|
||||
|
||||
const { getId } = getSelectors(this.page);
|
||||
const agentCard = getId("library-agent-card").filter({
|
||||
hasText: agent.name,
|
||||
});
|
||||
const seeRunsLink = getId("library-agent-card-see-runs-link", agentCard);
|
||||
await seeRunsLink.first().click();
|
||||
}
|
||||
|
||||
async clickOpenInBuilder(agent: Agent): Promise<void> {
|
||||
console.log(`clicking open in builder for agent: ${agent.name}`);
|
||||
|
||||
const { getId } = getSelectors(this.page);
|
||||
const agentCard = getId("library-agent-card").filter({
|
||||
hasText: agent.name,
|
||||
});
|
||||
const builderLink = getId(
|
||||
"library-agent-card-open-in-builder-link",
|
||||
agentCard,
|
||||
);
|
||||
await builderLink.first().click();
|
||||
}
|
||||
|
||||
async waitForAgentsToLoad(): Promise<void> {
|
||||
const { getId } = getSelectors(this.page);
|
||||
await Promise.race([
|
||||
getId("library-agent-card")
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10_000 }),
|
||||
getId("agents-count").waitFor({ state: "visible", timeout: 10_000 }),
|
||||
]);
|
||||
}
|
||||
|
||||
async getSearchValue(): Promise<string> {
|
||||
console.log(`getting search input value`);
|
||||
try {
|
||||
const searchInput = this.page.getByRole("textbox", {
|
||||
name: "Search agents",
|
||||
});
|
||||
return await searchInput.inputValue();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async hasNoAgentsMessage(): Promise<boolean> {
|
||||
const { getText } = getSelectors(this.page);
|
||||
const noAgentsText = getText("0 agents");
|
||||
return noAgentsText !== null;
|
||||
}
|
||||
|
||||
async scrollToBottom(): Promise<void> {
|
||||
console.log(`scrolling to bottom to trigger pagination`);
|
||||
await this.page.keyboard.press("End");
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async scrollDown(): Promise<void> {
|
||||
console.log(`scrolling down to trigger pagination`);
|
||||
await this.page.keyboard.press("PageDown");
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async scrollToLoadMore(): Promise<void> {
|
||||
console.log(`scrolling to load more agents`);
|
||||
|
||||
const initialCount = await this.getAgentCountByListLength();
|
||||
console.log(`Initial agent count (DOM cards): ${initialCount}`);
|
||||
|
||||
await this.scrollToBottom();
|
||||
|
||||
await this.page
|
||||
.waitForLoadState("networkidle", { timeout: 10000 })
|
||||
.catch(() => console.log("Network idle timeout, continuing..."));
|
||||
|
||||
await this.page
|
||||
.waitForFunction(
|
||||
(prevCount) =>
|
||||
document.querySelectorAll('[data-testid="library-agent-card"]')
|
||||
.length > prevCount,
|
||||
initialCount,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
const newCount = await this.getAgentCountByListLength();
|
||||
console.log(`New agent count after scroll (DOM cards): ${newCount}`);
|
||||
}
|
||||
|
||||
async testPagination(): Promise<{
|
||||
initialCount: number;
|
||||
finalCount: number;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const initialCount = await this.getAgentCountByListLength();
|
||||
await this.scrollToLoadMore();
|
||||
const finalCount = await this.getAgentCountByListLength();
|
||||
|
||||
const hasMore = finalCount > initialCount;
|
||||
return {
|
||||
initialCount,
|
||||
finalCount,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
async getAgentsWithPagination(): Promise<Agent[]> {
|
||||
console.log(`getting all agents with pagination`);
|
||||
|
||||
let allAgents: Agent[] = [];
|
||||
let previousCount = 0;
|
||||
let currentCount = 0;
|
||||
const maxAttempts = 5; // Prevent infinite loop
|
||||
let attempts = 0;
|
||||
|
||||
do {
|
||||
previousCount = currentCount;
|
||||
|
||||
// Get current agents
|
||||
const currentAgents = await this.getAgents();
|
||||
allAgents = currentAgents;
|
||||
currentCount = currentAgents.length;
|
||||
|
||||
console.log(`Attempt ${attempts + 1}: Found ${currentCount} agents`);
|
||||
|
||||
// Try to load more by scrolling
|
||||
await this.scrollToLoadMore();
|
||||
|
||||
attempts++;
|
||||
} while (currentCount > previousCount && attempts < maxAttempts);
|
||||
|
||||
console.log(`Total agents found with pagination: ${allAgents.length}`);
|
||||
return allAgents;
|
||||
}
|
||||
|
||||
async waitForPaginationLoad(): Promise<void> {
|
||||
console.log(`waiting for pagination to load`);
|
||||
|
||||
// Wait for any loading states to complete
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Wait for agent count to stabilize
|
||||
let previousCount = 0;
|
||||
let currentCount = 0;
|
||||
let stableChecks = 0;
|
||||
const maxChecks = 5; // Reduced from 10 to prevent excessive waiting
|
||||
|
||||
while (stableChecks < 2 && stableChecks < maxChecks) {
|
||||
currentCount = await this.getAgentCount();
|
||||
|
||||
if (currentCount === previousCount) {
|
||||
stableChecks++;
|
||||
} else {
|
||||
stableChecks = 0;
|
||||
}
|
||||
|
||||
previousCount = currentCount;
|
||||
if (stableChecks < 2) {
|
||||
// Only wait if we haven't stabilized yet
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Pagination load stabilized with ${currentCount} agents`);
|
||||
}
|
||||
|
||||
async scrollAndWaitForNewAgents(): Promise<number> {
|
||||
const initialCount = await this.getAgentCountByListLength();
|
||||
|
||||
await this.scrollDown();
|
||||
|
||||
await this.waitForPaginationLoad();
|
||||
|
||||
const finalCount = await this.getAgentCountByListLength();
|
||||
const newAgentsLoaded = finalCount - initialCount;
|
||||
|
||||
console.log(
|
||||
`Loaded ${newAgentsLoaded} new agents (${initialCount} -> ${finalCount})`,
|
||||
);
|
||||
|
||||
return newAgentsLoaded;
|
||||
}
|
||||
|
||||
async isPaginationWorking(): Promise<boolean> {
|
||||
const newAgentsLoaded = await this.scrollAndWaitForNewAgents();
|
||||
return newAgentsLoaded > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Locator functions
|
||||
export function getLibraryTab(page: Page): Locator {
|
||||
return page.locator('a[href="/library"]');
|
||||
}
|
||||
|
||||
export function getAgentCards(page: Page): Locator {
|
||||
return page.getByTestId("library-agent-card");
|
||||
}
|
||||
|
||||
export function getNewRunButton(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "New run" });
|
||||
}
|
||||
|
||||
export function getAgentTitle(page: Page): Locator {
|
||||
return page.locator("h1").first();
|
||||
}
|
||||
|
||||
// Action functions
|
||||
export async function navigateToLibrary(page: Page): Promise<void> {
|
||||
await getLibraryTab(page).click();
|
||||
await page.waitForURL(/.*\/library/);
|
||||
}
|
||||
|
||||
export async function clickFirstAgent(page: Page): Promise<void> {
|
||||
const firstAgent = getAgentCards(page).first();
|
||||
await firstAgent.click();
|
||||
}
|
||||
|
||||
export async function navigateToAgentByName(
|
||||
page: Page,
|
||||
agentName: string,
|
||||
): Promise<void> {
|
||||
const agentCard = getAgentCards(page).filter({ hasText: agentName }).first();
|
||||
// Wait for the agent card to be visible before clicking
|
||||
// This handles async loading of agents after page navigation
|
||||
await agentCard.waitFor({ state: "visible", timeout: 15000 });
|
||||
// Click the link inside the card to navigate reliably through
|
||||
// the motion.div + draggable wrapper layers.
|
||||
const link = agentCard.locator('a[href*="/library/agents/"]').first();
|
||||
await link.click();
|
||||
}
|
||||
|
||||
export async function clickRunButton(page: Page): Promise<void> {
|
||||
const { getId } = getSelectors(page);
|
||||
|
||||
// Wait for sidebar loading to complete before detecting buttons.
|
||||
// During sidebar loading, the "New task" button appears transiently
|
||||
// even for agents with no items, then switches to "Setup your task"
|
||||
// once loading finishes. Waiting for network idle ensures the page
|
||||
// has settled into its final state.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const setupTaskButton = page.getByRole("button", {
|
||||
name: /Setup your task/i,
|
||||
});
|
||||
const newTaskButton = page.getByRole("button", { name: /New task/i });
|
||||
const runButton = getId("agent-run-button");
|
||||
const runAgainButton = getId("run-again-button");
|
||||
|
||||
// Wait for any of the buttons to appear
|
||||
try {
|
||||
await Promise.race([
|
||||
setupTaskButton.waitFor({ state: "visible", timeout: 15000 }),
|
||||
newTaskButton.waitFor({ state: "visible", timeout: 15000 }),
|
||||
runButton.waitFor({ state: "visible", timeout: 15000 }),
|
||||
runAgainButton.waitFor({ state: "visible", timeout: 15000 }),
|
||||
]);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Could not find run/start task button - none of the expected buttons appeared",
|
||||
);
|
||||
}
|
||||
|
||||
// Check which button is visible and click it
|
||||
if (await setupTaskButton.isVisible()) {
|
||||
await setupTaskButton.click();
|
||||
const startBtn = page.getByRole("button", { name: /Start Task/i }).first();
|
||||
await startBtn.waitFor({ state: "visible", timeout: 15000 });
|
||||
await startBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await newTaskButton.isVisible()) {
|
||||
await newTaskButton.click();
|
||||
const startBtn = page.getByRole("button", { name: /Start Task/i }).first();
|
||||
await startBtn.waitFor({ state: "visible", timeout: 15000 });
|
||||
await startBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runButton.isVisible()) {
|
||||
await runButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runAgainButton.isVisible()) {
|
||||
await runAgainButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Could not find run/start task button");
|
||||
}
|
||||
|
||||
export async function clickNewRunButton(page: Page): Promise<void> {
|
||||
await getNewRunButton(page).click();
|
||||
}
|
||||
|
||||
export async function runAgent(page: Page): Promise<void> {
|
||||
await clickRunButton(page);
|
||||
}
|
||||
|
||||
export async function waitForAgentPageLoad(page: Page): Promise<void> {
|
||||
await page.waitForURL(/.*\/library\/agents\/[^/]+/);
|
||||
// Wait for sidebar data to finish loading so the page settles
|
||||
// into its final state (empty view vs sidebar view)
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
export async function getAgentName(page: Page): Promise<string> {
|
||||
return (await getAgentTitle(page).textContent()) || "";
|
||||
}
|
||||
|
||||
export async function isLoaded(page: Page): Promise<boolean> {
|
||||
return await page.locator("h1").isVisible();
|
||||
}
|
||||
|
||||
export async function waitForRunToComplete(
|
||||
page: Page,
|
||||
timeout = 30000,
|
||||
): Promise<void> {
|
||||
await page.waitForSelector(".bg-green-500, .bg-red-500, .bg-purple-500", {
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRunStatus(page: Page): Promise<string> {
|
||||
if (await page.locator(".animate-spin").isVisible()) {
|
||||
return "running";
|
||||
} else if (await page.locator(".bg-green-500").isVisible()) {
|
||||
return "completed";
|
||||
} else if (await page.locator(".bg-red-500").isVisible()) {
|
||||
return "failed";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { skipOnboardingIfPresent } from "../utils/onboarding";
|
||||
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login");
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
console.log(`ℹ️ Attempting login on ${this.page.url()} with`, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Wait for the form to be ready
|
||||
await this.page.waitForSelector("form", { state: "visible" });
|
||||
|
||||
// Fill email using input selector instead of label
|
||||
const emailInput = this.page.locator('input[type="email"]');
|
||||
await emailInput.waitFor({ state: "visible" });
|
||||
await emailInput.fill(email);
|
||||
|
||||
// Fill password using input selector instead of label
|
||||
const passwordInput = this.page.locator('input[type="password"]');
|
||||
await passwordInput.waitFor({ state: "visible" });
|
||||
await passwordInput.fill(password);
|
||||
|
||||
// Wait for the button to be ready
|
||||
const loginButton = this.page.getByRole("button", {
|
||||
name: "Login",
|
||||
exact: true,
|
||||
});
|
||||
await loginButton.waitFor({ state: "visible" });
|
||||
|
||||
// Attach navigation logger for debug purposes
|
||||
this.page.on("load", (page) => console.log(`ℹ️ Now at URL: ${page.url()}`));
|
||||
|
||||
// Start waiting for navigation before clicking
|
||||
// Wait for redirect to marketplace, onboarding, library, or copilot (new landing pages)
|
||||
const leaveLoginPage = this.page
|
||||
.waitForURL(
|
||||
(url: URL) =>
|
||||
/^\/(marketplace|onboarding(\/.*)?|library|copilot)?$/.test(
|
||||
url.pathname,
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error(
|
||||
`🚨 Navigation away from /login timed out (current URL: ${this.page.url()}):`,
|
||||
reason,
|
||||
);
|
||||
throw reason;
|
||||
});
|
||||
|
||||
console.log(`🖱️ Clicking login button...`);
|
||||
await loginButton.click();
|
||||
|
||||
console.log("⏳ Waiting for navigation away from /login ...");
|
||||
await leaveLoginPage;
|
||||
console.log(`⌛ Post-login redirected to ${this.page.url()}`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)); // allow time for client-side redirect
|
||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||
|
||||
// If redirected to onboarding, complete it via API so tests can proceed
|
||||
await skipOnboardingIfPresent(this.page, "/marketplace");
|
||||
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 20_000 });
|
||||
console.log("✅ Login process complete");
|
||||
|
||||
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions
|
||||
try {
|
||||
const walletPanel = this.page.getByText("Your credits").first();
|
||||
// Wait briefly for wallet to appear after navigation (it may open asynchronously)
|
||||
const appeared = await walletPanel
|
||||
.waitFor({ state: "visible", timeout: 2500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (appeared) {
|
||||
const closeWalletButton = this.page.getByRole("button", {
|
||||
name: /Close wallet/i,
|
||||
});
|
||||
await closeWalletButton.click({ timeout: 3000 }).catch(async () => {
|
||||
// Fallbacks: try Escape, then click outside
|
||||
await this.page.keyboard.press("Escape").catch(() => {});
|
||||
});
|
||||
await walletPanel
|
||||
.waitFor({ state: "hidden", timeout: 3000 })
|
||||
.catch(async () => {
|
||||
await this.page.mouse.click(5, 5).catch(() => {});
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Non-fatal in tests; continue
|
||||
console.log("(info) Wallet popover not present or already closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
import { getSelectors } from "../utils/selectors";
|
||||
|
||||
export class MarketplacePage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async goto(page: Page) {
|
||||
await page.goto("/marketplace");
|
||||
await page
|
||||
.locator(
|
||||
'[data-testid="store-card"], [data-testid="featured-store-card"]',
|
||||
)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 20000 });
|
||||
}
|
||||
|
||||
async getMarketplaceTitle(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Explore AI agents", { exact: false });
|
||||
}
|
||||
|
||||
async getCreatorsSection(page: Page) {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
return getId("creators-section") || getText("Creators", { exact: false });
|
||||
}
|
||||
|
||||
async getAgentsSection(page: Page) {
|
||||
const { getId, getText } = getSelectors(page);
|
||||
return getId("agents-section") || getText("Agents", { exact: false });
|
||||
}
|
||||
|
||||
async getCreatorsLink(page: Page) {
|
||||
const { getLink } = getSelectors(page);
|
||||
return getLink(/creators/i);
|
||||
}
|
||||
|
||||
async getAgentsLink(page: Page) {
|
||||
const { getLink } = getSelectors(page);
|
||||
return getLink(/agents/i);
|
||||
}
|
||||
|
||||
async getSearchInput(page: Page) {
|
||||
const { getField, getId } = getSelectors(page);
|
||||
return getId("store-search-input") || getField(/search/i);
|
||||
}
|
||||
|
||||
async getFilterDropdown(page: Page) {
|
||||
const { getId, getButton } = getSelectors(page);
|
||||
return getId("filter-dropdown") || getButton(/filter/i);
|
||||
}
|
||||
|
||||
async searchFor(query: string, page: Page) {
|
||||
const searchInput = await this.getSearchInput(page);
|
||||
await searchInput.fill(query);
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async clickCreators(page: Page) {
|
||||
const creatorsLink = await this.getCreatorsLink(page);
|
||||
await creatorsLink.click();
|
||||
}
|
||||
|
||||
async clickAgents(page: Page) {
|
||||
const agentsLink = await this.getAgentsLink(page);
|
||||
await agentsLink.click();
|
||||
}
|
||||
|
||||
async openFilter(page: Page) {
|
||||
const filterDropdown = await this.getFilterDropdown(page);
|
||||
await filterDropdown.click();
|
||||
}
|
||||
|
||||
async getFeaturedAgentsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Featured agents");
|
||||
}
|
||||
|
||||
async getTopAgentsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("All Agents");
|
||||
}
|
||||
|
||||
async getFeaturedCreatorsSection(page: Page) {
|
||||
const { getText } = getSelectors(page);
|
||||
return getText("Featured Creators");
|
||||
}
|
||||
|
||||
async getFeaturedAgentCards(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("featured-store-card");
|
||||
}
|
||||
|
||||
async getTopAgentCards(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("store-card");
|
||||
}
|
||||
|
||||
async getCreatorProfiles(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
return getId("creator-card");
|
||||
}
|
||||
|
||||
async searchAndNavigate(query: string, page: Page) {
|
||||
const searchInput = (await this.getSearchInput(page)).first();
|
||||
await searchInput.fill(query);
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async waitForSearchResults() {
|
||||
await this.page.waitForURL("**/marketplace/search**");
|
||||
}
|
||||
|
||||
async getFirstFeaturedAgent(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const card = getId("featured-store-card").first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getFirstTopAgent() {
|
||||
const card = this.page
|
||||
.locator('[data-testid="store-card"]:visible')
|
||||
.first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getFirstCreatorProfile(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const card = getId("creator-card").first();
|
||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
async getSearchResultsCount(page: Page) {
|
||||
const { getId } = getSelectors(page);
|
||||
const storeCards = getId("store-card");
|
||||
return await storeCards.count();
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { ProfileFormPage } from "./pages/profile-form.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
|
||||
test.describe("Profile Form", () => {
|
||||
let profileFormPage: ProfileFormPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
profileFormPage = new ProfileFormPage(page);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("redirects to login when user is not authenticated", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/profile");
|
||||
await hasUrl(page, "/login?next=%2Fprofile");
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("can save profile changes successfully", async ({ page }) => {
|
||||
await profileFormPage.navbar.clickProfileLink();
|
||||
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const newDisplayName = `E2E Name ${suffix}`;
|
||||
const newHandle = `e2euser${suffix}`;
|
||||
const newBio = `E2E bio ${suffix}`;
|
||||
const newLinks = [
|
||||
`https://example.com/${suffix}/1`,
|
||||
`https://example.com/${suffix}/2`,
|
||||
`https://example.com/${suffix}/3`,
|
||||
`https://example.com/${suffix}/4`,
|
||||
`https://example.com/${suffix}/5`,
|
||||
];
|
||||
|
||||
await profileFormPage.setDisplayName(newDisplayName);
|
||||
await profileFormPage.setHandle(newHandle);
|
||||
await profileFormPage.setBio(newBio);
|
||||
await profileFormPage.setLinks(newLinks);
|
||||
await profileFormPage.saveChanges();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(newDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(newHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(newBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(newLinks[i - 1]);
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(newDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(newHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(newBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(newLinks[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Currently we are not using hook form inside the profile form, so cancel button is not working as expected, once that's fixed, we can unskip this test
|
||||
test.skip("can cancel profile changes", async ({ page }) => {
|
||||
await profileFormPage.navbar.clickProfileLink();
|
||||
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
|
||||
const originalDisplayName = await profileFormPage.getDisplayName();
|
||||
const originalHandle = await profileFormPage.getHandle();
|
||||
const originalBio = await profileFormPage.getBio();
|
||||
const originalLinks: string[] = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
originalLinks.push(await profileFormPage.getLink(i));
|
||||
}
|
||||
|
||||
const suffix = `${Date.now().toString().slice(-6)}_cancel`;
|
||||
await profileFormPage.setDisplayName(`Tmp Name ${suffix}`);
|
||||
await profileFormPage.setHandle(`tmpuser${suffix}`);
|
||||
await profileFormPage.setBio(`Tmp bio ${suffix}`);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await profileFormPage.setLink(i, `https://tmp.example/${suffix}/${i}`);
|
||||
}
|
||||
|
||||
await profileFormPage.clickCancel();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(originalDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(originalHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(originalBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(originalLinks[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { ProfilePage } from "./pages/profile.page";
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const testUser = await getTestUser();
|
||||
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("user can view their profile information", async ({ page }) => {
|
||||
const profilePage = new ProfilePage(page);
|
||||
|
||||
await profilePage.navbar.clickProfileLink();
|
||||
|
||||
// workaround for #8788
|
||||
// sleep for 10 seconds to allow page to load due to bug in our system
|
||||
await page.waitForTimeout(10000);
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
await expect(profilePage.isLoaded()).resolves.toBeTruthy();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
|
||||
// Verify email matches test worker's email
|
||||
const displayedHandle = await profilePage.getDisplayedName();
|
||||
expect(displayedHandle).not.toBeNull();
|
||||
expect(displayedHandle).not.toBe("");
|
||||
expect(displayedHandle).toBeDefined();
|
||||
});
|
||||
|
||||
test("profile navigation is accessible from navbar", async ({ page }) => {
|
||||
const profilePage = new ProfilePage(page);
|
||||
|
||||
await profilePage.navbar.clickProfileLink();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
await expect(profilePage.isLoaded()).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("profile displays user Credential providers", async ({ page }) => {
|
||||
const profilePage = new ProfilePage(page);
|
||||
await profilePage.navbar.clickProfileLink();
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
import { test } from "./coverage-fixture";
|
||||
import { getTestUserWithLibraryAgents } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import {
|
||||
hasUrl,
|
||||
isDisabled,
|
||||
isEnabled,
|
||||
isHidden,
|
||||
isVisible,
|
||||
} from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test("user can publish an agent through the complete flow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { getId, getText, getButton } = getSelectors(page);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto("/login");
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/marketplace");
|
||||
await getButton("Become a creator").click();
|
||||
|
||||
const publishAgentModal = getId("publish-agent-modal");
|
||||
await isVisible(publishAgentModal, 10000);
|
||||
|
||||
await isVisible(
|
||||
publishAgentModal.getByText(
|
||||
"Select your project that you'd like to publish",
|
||||
),
|
||||
);
|
||||
|
||||
const agentToSelect = publishAgentModal.getByTestId("agent-card").first();
|
||||
await agentToSelect.click();
|
||||
|
||||
const nextButton = publishAgentModal.getByRole("button", {
|
||||
name: "Next",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await isEnabled(nextButton);
|
||||
await nextButton.click();
|
||||
|
||||
// 2. Adding details of agent
|
||||
await isVisible(getText("Write a bit of details about your agent"));
|
||||
|
||||
const agentName = "Test Agent Name";
|
||||
|
||||
const agentTitle = publishAgentModal.getByLabel("Title");
|
||||
await agentTitle.fill(agentName);
|
||||
|
||||
const agentSubheader = publishAgentModal.getByLabel("Subheader");
|
||||
await agentSubheader.fill("Test Agent Subheader");
|
||||
|
||||
const agentSlug = publishAgentModal.getByLabel("Slug");
|
||||
await agentSlug.fill("test-agent-slug");
|
||||
|
||||
const youtubeInput = publishAgentModal.getByLabel("Youtube video link");
|
||||
await youtubeInput.fill("https://www.youtube.com/watch?v=test");
|
||||
|
||||
const categorySelect = publishAgentModal.locator(
|
||||
'select[aria-hidden="true"]',
|
||||
);
|
||||
await categorySelect.selectOption({ value: "other" });
|
||||
|
||||
const descriptionInput = publishAgentModal.getByLabel("Description");
|
||||
await descriptionInput.fill(
|
||||
"This is a test agent description for the automated test.",
|
||||
);
|
||||
|
||||
await isEnabled(publishAgentModal.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
|
||||
test("should display appropriate content in agent creation modal when user is logged out", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { getText, getButton } = getSelectors(page);
|
||||
|
||||
await page.goto("/marketplace");
|
||||
await getButton("Become a creator").click();
|
||||
|
||||
await isVisible(
|
||||
getText(
|
||||
"Log in or create an account to publish your agents to the marketplace and join a community of creators",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("should validate all form fields in publish agent form", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { getId, getText, getButton } = getSelectors(page);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto("/login");
|
||||
const richUser = getTestUserWithLibraryAgents();
|
||||
await loginPage.login(richUser.email, richUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/marketplace");
|
||||
await getButton("Become a creator").click();
|
||||
|
||||
const publishAgentModal = getId("publish-agent-modal");
|
||||
await isVisible(publishAgentModal, 10000);
|
||||
|
||||
const agentToSelect = publishAgentModal.getByTestId("agent-card").first();
|
||||
await agentToSelect.click();
|
||||
|
||||
const nextButton = publishAgentModal.getByRole("button", {
|
||||
name: "Next",
|
||||
exact: true,
|
||||
});
|
||||
await nextButton.click();
|
||||
|
||||
await isVisible(getText("Write a bit of details about your agent"));
|
||||
|
||||
// Get form elements
|
||||
const agentTitle = publishAgentModal.getByLabel("Title");
|
||||
const agentSubheader = publishAgentModal.getByLabel("Subheader");
|
||||
const agentSlug = publishAgentModal.getByLabel("Slug");
|
||||
const youtubeInput = publishAgentModal.getByLabel("Youtube video link");
|
||||
const categorySelect = publishAgentModal.locator(
|
||||
'select[aria-hidden="true"]',
|
||||
);
|
||||
const descriptionInput = publishAgentModal.getByLabel("Description");
|
||||
const submitButton = publishAgentModal.getByRole("button", {
|
||||
name: "Submit",
|
||||
});
|
||||
|
||||
async function clearForm() {
|
||||
await agentTitle.clear();
|
||||
await agentSubheader.clear();
|
||||
await agentSlug.clear();
|
||||
await youtubeInput.clear();
|
||||
await descriptionInput.clear();
|
||||
}
|
||||
|
||||
// 1. Test required field validations
|
||||
await clearForm();
|
||||
await submitButton.click();
|
||||
|
||||
await isVisible(publishAgentModal.getByText("Title is required"));
|
||||
await isVisible(publishAgentModal.getByText("Subheader is required"));
|
||||
await isVisible(publishAgentModal.getByText("Slug is required"));
|
||||
await isVisible(publishAgentModal.getByText("Category is required"));
|
||||
await isVisible(publishAgentModal.getByText("Description is required"));
|
||||
|
||||
// 2. Test field length limits
|
||||
await clearForm();
|
||||
|
||||
// Test title length limit (100 characters)
|
||||
const longTitle = "a".repeat(101);
|
||||
await agentTitle.fill(longTitle);
|
||||
await agentTitle.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText("Title must be less than 100 characters"),
|
||||
);
|
||||
|
||||
// Test subheader length limit (200 characters)
|
||||
const longSubheader = "b".repeat(201);
|
||||
await agentSubheader.fill(longSubheader);
|
||||
await agentSubheader.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText("Subheader must be less than 200 characters"),
|
||||
);
|
||||
|
||||
// Test slug length limit (50 characters)
|
||||
const longSlug = "c".repeat(51);
|
||||
await agentSlug.fill(longSlug);
|
||||
await agentSlug.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText("Slug must be less than 50 characters"),
|
||||
);
|
||||
|
||||
// Test description length limit (1000 characters)
|
||||
const longDescription = "d".repeat(1001);
|
||||
await descriptionInput.fill(longDescription);
|
||||
await descriptionInput.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText(
|
||||
"Description must be less than 1000 characters",
|
||||
),
|
||||
);
|
||||
|
||||
// Test invalid characters in slug
|
||||
await agentSlug.fill("Invalid Slug With Spaces");
|
||||
await agentSlug.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText(
|
||||
"Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
),
|
||||
);
|
||||
|
||||
await agentSlug.clear();
|
||||
await agentSlug.fill("InvalidSlugWithCapitals");
|
||||
await agentSlug.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText(
|
||||
"Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
),
|
||||
);
|
||||
|
||||
await agentSlug.clear();
|
||||
await agentSlug.fill("invalid-slug-with-@#$");
|
||||
await agentSlug.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText(
|
||||
"Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
),
|
||||
);
|
||||
|
||||
// Test valid slug format should not show error
|
||||
await agentSlug.clear();
|
||||
await agentSlug.fill("valid-slug-123");
|
||||
await agentSlug.blur();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await isHidden(
|
||||
publishAgentModal.getByText(
|
||||
"Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
),
|
||||
);
|
||||
|
||||
// Test invalid YouTube URL
|
||||
await youtubeInput.fill("https://www.google.com/invalid-url");
|
||||
await youtubeInput.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText("Please enter a valid YouTube URL"),
|
||||
);
|
||||
|
||||
await youtubeInput.clear();
|
||||
await youtubeInput.fill("not-a-url-at-all");
|
||||
await youtubeInput.blur();
|
||||
await isVisible(
|
||||
publishAgentModal.getByText("Please enter a valid YouTube URL"),
|
||||
);
|
||||
|
||||
// Test valid YouTube URLs should not show error
|
||||
await youtubeInput.clear();
|
||||
await youtubeInput.fill("https://www.youtube.com/watch?v=test");
|
||||
await youtubeInput.blur();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await isHidden(
|
||||
publishAgentModal.getByText("Please enter a valid YouTube URL"),
|
||||
);
|
||||
|
||||
await youtubeInput.clear();
|
||||
await youtubeInput.fill("https://youtu.be/test123");
|
||||
await youtubeInput.blur();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await isHidden(
|
||||
publishAgentModal.getByText("Please enter a valid YouTube URL"),
|
||||
);
|
||||
|
||||
// 5. Test submit button enabled/disabled state
|
||||
await clearForm();
|
||||
|
||||
// Submit button should be disabled when form is empty
|
||||
await page.waitForTimeout(1000);
|
||||
await isDisabled(submitButton);
|
||||
|
||||
// Fill all required fields with valid data
|
||||
await agentTitle.fill("Valid Title");
|
||||
await agentSubheader.fill("Valid Subheader");
|
||||
await agentSlug.fill("valid-slug");
|
||||
await categorySelect.selectOption({ value: "other" });
|
||||
await descriptionInput.fill("Valid description text");
|
||||
|
||||
// Submit button should now be enabled
|
||||
await isEnabled(submitButton);
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasAttribute, hasUrl, isHidden, isVisible } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
// Login and navigate to settings
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
// Navigate to settings page
|
||||
await page.goto("/profile/settings");
|
||||
await hasUrl(page, "/profile/settings");
|
||||
});
|
||||
|
||||
test("should display email form elements correctly", async ({ page }) => {
|
||||
const { getField, getButton, getText, getLink } = getSelectors(page);
|
||||
|
||||
// Check email form elements are displayed
|
||||
await isVisible(getText("Security & Access"));
|
||||
await isVisible(getField("Email"));
|
||||
await isVisible(getLink("Reset password"));
|
||||
await isVisible(getButton("Update email"));
|
||||
|
||||
const updateEmailButton = getButton("Update email");
|
||||
const resetPasswordButton = getLink("Reset password");
|
||||
|
||||
// Button should be disabled initially (no changes)
|
||||
await expect(updateEmailButton).toBeDisabled();
|
||||
|
||||
// Test reset password navigation
|
||||
await hasAttribute(resetPasswordButton, "href", "/reset-password");
|
||||
});
|
||||
|
||||
test("should show validation error for empty email", async ({ page }) => {
|
||||
const { getField, getButton } = getSelectors(page);
|
||||
|
||||
const emailField = getField("Email");
|
||||
const updateEmailButton = getButton("Update email");
|
||||
|
||||
await emailField.fill("");
|
||||
await updateEmailButton.click();
|
||||
await isVisible(page.getByText("Email is required"));
|
||||
});
|
||||
|
||||
test("should show validation error for invalid email", async ({ page }) => {
|
||||
const { getField, getButton } = getSelectors(page);
|
||||
|
||||
const emailField = getField("Email");
|
||||
const updateEmailButton = getButton("Update email");
|
||||
|
||||
await emailField.fill("invalid email");
|
||||
await updateEmailButton.click();
|
||||
await isVisible(page.getByText("Please enter a valid email address"));
|
||||
});
|
||||
|
||||
test("should handle valid email", async ({ page }) => {
|
||||
const { getField, getButton } = getSelectors(page);
|
||||
|
||||
const emailField = getField("Email");
|
||||
const updateEmailButton = getButton("Update email");
|
||||
|
||||
// Test successful email update
|
||||
const newEmail = `test+${Date.now()}@example.com`;
|
||||
await emailField.fill(newEmail);
|
||||
await expect(updateEmailButton).toBeEnabled();
|
||||
await updateEmailButton.click();
|
||||
await isHidden(page.getByText("Email is required"));
|
||||
await isHidden(page.getByText("Please enter a valid email address"));
|
||||
});
|
||||
|
||||
test("should handle complete notification form functionality and form interactions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { getButton } = getSelectors(page);
|
||||
|
||||
// Check notification form elements are displayed
|
||||
await isVisible(
|
||||
page.getByRole("heading", { name: "Notifications", exact: true }),
|
||||
);
|
||||
|
||||
await isVisible(getButton("Cancel"));
|
||||
await isVisible(getButton("Save preferences"));
|
||||
|
||||
// Check all notification switches are present - get all switches on page
|
||||
const switches = await page.getByRole("switch").all();
|
||||
|
||||
for (const switchElement of switches) {
|
||||
await isVisible(switchElement);
|
||||
}
|
||||
|
||||
const savePreferencesButton = getButton("Save preferences");
|
||||
const cancelButton = getButton("Cancel");
|
||||
|
||||
// Button should be disabled initially (no changes)
|
||||
await expect(savePreferencesButton).toBeDisabled();
|
||||
|
||||
// Test switch toggling functionality
|
||||
for (const switchElement of switches) {
|
||||
const initialState = await switchElement.isChecked();
|
||||
await switchElement.click();
|
||||
const newState = await switchElement.isChecked();
|
||||
expect(newState).toBe(!initialState);
|
||||
}
|
||||
|
||||
// Test button enabling when changes are made
|
||||
if (switches.length > 0) {
|
||||
await expect(savePreferencesButton).toBeEnabled();
|
||||
}
|
||||
|
||||
// Test cancel functionality
|
||||
await cancelButton.click();
|
||||
// Wait for form state to update after cancel
|
||||
await page.waitForTimeout(100);
|
||||
await expect(savePreferencesButton).toBeDisabled();
|
||||
|
||||
// Test successful save with multiple switches
|
||||
const testSwitches = switches.slice(0, Math.min(3, switches.length));
|
||||
for (const switchElement of testSwitches) {
|
||||
await switchElement.click();
|
||||
}
|
||||
await expect(savePreferencesButton).toBeEnabled();
|
||||
await savePreferencesButton.click();
|
||||
await isVisible(getButton("Saving..."));
|
||||
await isVisible(
|
||||
page.getByText("Successfully updated notification preferences"),
|
||||
);
|
||||
|
||||
// Test persistence after page reload
|
||||
if (testSwitches.length > 0) {
|
||||
const finalState = await testSwitches[0].isChecked();
|
||||
await page.reload();
|
||||
await hasUrl(page, "/profile/settings");
|
||||
const reloadedSwitches = await page.getByRole("switch").all();
|
||||
if (reloadedSwitches.length > 0) {
|
||||
expect(await reloadedSwitches[0].isChecked()).toBe(finalState);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
// auth.spec.ts
|
||||
|
||||
import { test } from "./coverage-fixture";
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl, isHidden, isVisible } from "./utils/assertion";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
});
|
||||
|
||||
test("check the navigation when logged out", async ({ page }) => {
|
||||
const { getButton, getText, getLink } = getSelectors(page);
|
||||
|
||||
// Test marketplace link
|
||||
const marketplaceLink = getLink("Marketplace");
|
||||
await isVisible(marketplaceLink);
|
||||
await marketplaceLink.click();
|
||||
await hasUrl(page, "/marketplace");
|
||||
await isVisible(getText("Explore AI agents", { exact: false }));
|
||||
|
||||
// Test login button
|
||||
const loginBtn = getButton("Log In");
|
||||
await isVisible(loginBtn);
|
||||
await loginBtn.click();
|
||||
await hasUrl(page, "/login");
|
||||
await isHidden(loginBtn);
|
||||
});
|
||||
|
||||
test("user can login successfully", async ({ page }) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
const { getId, getButton, getRole } = getSelectors(page);
|
||||
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
const accountMenuTrigger = getId("profile-popout-menu-trigger");
|
||||
|
||||
await isVisible(accountMenuTrigger);
|
||||
|
||||
await accountMenuTrigger.click();
|
||||
const accountMenuPopover = getRole("dialog");
|
||||
await isVisible(accountMenuPopover);
|
||||
|
||||
const accountMenuUserEmail = getId("account-menu-user-email");
|
||||
await isVisible(accountMenuUserEmail);
|
||||
await test
|
||||
.expect(accountMenuUserEmail)
|
||||
.toHaveText(testUser.email.split("@")[0].toLowerCase());
|
||||
|
||||
const logoutBtn = getButton("Log out");
|
||||
await isVisible(logoutBtn);
|
||||
await logoutBtn.click();
|
||||
});
|
||||
|
||||
test("user can logout successfully", async ({ page }) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
const { getButton, getId } = getSelectors(page);
|
||||
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
// Open account menu
|
||||
await getId("profile-popout-menu-trigger").click();
|
||||
|
||||
// Logout
|
||||
await getButton("Log out").click();
|
||||
await hasUrl(page, "/login");
|
||||
});
|
||||
|
||||
test("login in, then out, then in again", async ({ page }) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
const { getButton, getId } = getSelectors(page);
|
||||
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
// Click on the profile menu trigger to open account menu
|
||||
await getId("profile-popout-menu-trigger").click();
|
||||
|
||||
// Click the logout button in the popout menu
|
||||
await getButton("Log out").click();
|
||||
|
||||
await test.expect(page).toHaveURL("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await test.expect(page).toHaveURL("/marketplace");
|
||||
await test
|
||||
.expect(page.getByTestId("profile-popout-menu-trigger"))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test("multi-tab logout with WebSocket cleanup", async ({ context }) => {
|
||||
const testUser = await getTestUser();
|
||||
|
||||
// Tab 1
|
||||
const page1 = await context.newPage();
|
||||
const builderPage1 = new BuildPage(page1);
|
||||
|
||||
// Capture console errors to ensure WebSocket cleanup prevents errors
|
||||
const consoleErrors: string[] = [];
|
||||
page1.on("console", (msg) => {
|
||||
if (msg.type() === "error" && msg.text().includes("WebSocket")) {
|
||||
consoleErrors.push(`Page1: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const loginPage1 = new LoginPage(page1);
|
||||
const { getButton: getButton1, getId: getId1 } = getSelectors(page1);
|
||||
|
||||
// Login
|
||||
await page1.goto("/login");
|
||||
await loginPage1.login(testUser.email, testUser.password);
|
||||
await hasUrl(page1, "/marketplace");
|
||||
|
||||
// Navigate to builder + wait for WebSocket connection
|
||||
await page1.goto("/build");
|
||||
await hasUrl(page1, "/build");
|
||||
await builderPage1.closeTutorial();
|
||||
await page1.waitForTimeout(1000);
|
||||
await isVisible(getId1("profile-popout-menu-trigger"));
|
||||
|
||||
// Tab 2
|
||||
const page2 = await context.newPage();
|
||||
|
||||
const { getId: getId2 } = getSelectors(page2);
|
||||
|
||||
page2.on("console", (msg) => {
|
||||
if (msg.type() === "error" && msg.text().includes("WebSocket")) {
|
||||
consoleErrors.push(`Page2: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to builder + wait for WebSocket connection
|
||||
await page2.goto("/build");
|
||||
await hasUrl(page2, "/build");
|
||||
await page2.waitForTimeout(1000);
|
||||
await isVisible(getId2("profile-popout-menu-trigger"));
|
||||
|
||||
// Tab 1: Logout
|
||||
await getId1("profile-popout-menu-trigger").click();
|
||||
await getButton1("Log out").click();
|
||||
await hasUrl(page1, "/login");
|
||||
|
||||
// Tab 2: Wait for cross-tab logout to take effect and check if redirected to login
|
||||
await page2.waitForTimeout(2000); // Give time for cross-tab logout mechanism
|
||||
|
||||
// Check if Tab 2 has been redirected to login or refresh the page to trigger redirect
|
||||
try {
|
||||
await page2.reload();
|
||||
await hasUrl(page2, "/login?next=%2Fbuild");
|
||||
} catch {
|
||||
// If reload fails, the page might already be redirecting
|
||||
await hasUrl(page2, "/login?next=%2Fbuild");
|
||||
}
|
||||
|
||||
// Verify the profile menu is no longer visible (user is logged out)
|
||||
await isHidden(getId2("profile-popout-menu-trigger"));
|
||||
|
||||
// Verify no WebSocket connection errors occurred during logout
|
||||
test.expect(consoleErrors).toHaveLength(0);
|
||||
if (consoleErrors.length > 0) {
|
||||
console.log("WebSocket errors during logout:", consoleErrors);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await page1.close();
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test("logged in user is redirected from /login to /copilot", async ({
|
||||
page,
|
||||
}) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/login");
|
||||
await hasUrl(page, "/copilot");
|
||||
});
|
||||
|
||||
test("logged in user is redirected from /signup to /copilot", async ({
|
||||
page,
|
||||
}) => {
|
||||
const testUser = await getTestUser();
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/signup");
|
||||
await hasUrl(page, "/copilot");
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import {
|
||||
generateTestEmail,
|
||||
generateTestPassword,
|
||||
signupTestUser,
|
||||
validateSignupForm,
|
||||
} from "./utils/signup";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
import { hasUrl, isVisible } from "./utils/assertion";
|
||||
|
||||
test("user can signup successfully", async ({ page }) => {
|
||||
try {
|
||||
const testUser = await signupTestUser(page);
|
||||
const { getText, getId } = getSelectors(page);
|
||||
|
||||
// Verify user was created
|
||||
expect(testUser.email).toBeTruthy();
|
||||
expect(testUser.password).toBeTruthy();
|
||||
expect(testUser.createdAt).toBeTruthy();
|
||||
|
||||
const marketplaceText = getText(
|
||||
"Bringing you AI agents designed by thinkers from around the world",
|
||||
).first();
|
||||
|
||||
// Verify we're on marketplace and authenticated
|
||||
await hasUrl(page, "/marketplace");
|
||||
await isVisible(marketplaceText);
|
||||
await isVisible(getId("profile-popout-menu-trigger"));
|
||||
} catch (error) {
|
||||
console.error("❌ Signup test failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
test("signup form validation works", async ({ page }) => {
|
||||
const { getField, getRole, getButton } = getSelectors(page);
|
||||
const emailInput = getField("Email");
|
||||
const passwordInput = page.locator("#password");
|
||||
const confirmPasswordInput = page.locator("#confirmPassword");
|
||||
const signupButton = getButton("Sign up");
|
||||
const termsCheckbox = getRole("checkbox");
|
||||
|
||||
await validateSignupForm(page);
|
||||
|
||||
// Additional validation tests
|
||||
await page.goto("/signup");
|
||||
|
||||
// Test with mismatched passwords
|
||||
await emailInput.fill(generateTestEmail());
|
||||
await passwordInput.fill("password1");
|
||||
await confirmPasswordInput.fill("password2");
|
||||
await termsCheckbox.click();
|
||||
await signupButton.click();
|
||||
|
||||
// Should still be on signup page
|
||||
await hasUrl(page, /\/signup/);
|
||||
});
|
||||
|
||||
test("user can signup with custom credentials", async ({ page }) => {
|
||||
const { getId } = getSelectors(page);
|
||||
|
||||
try {
|
||||
const customEmail = generateTestEmail();
|
||||
const customPassword = await 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 hasUrl(page, "/marketplace");
|
||||
await isVisible(getId("profile-popout-menu-trigger"));
|
||||
} catch (error) {
|
||||
console.error("❌ Custom credentials signup test failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
test("user can signup with existing email handling", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
try {
|
||||
const testEmail = generateTestEmail();
|
||||
const testPassword = await generateTestPassword();
|
||||
|
||||
// First signup
|
||||
const firstUser = await signupTestUser(page, testEmail, testPassword);
|
||||
expect(firstUser.email).toBe(testEmail);
|
||||
|
||||
// Create new browser context for second signup (simulates new browser window)
|
||||
const newContext = await browser.newContext();
|
||||
const newPage = await newContext.newPage();
|
||||
|
||||
try {
|
||||
const { getText, getField, getRole, getButton } = getSelectors(newPage);
|
||||
|
||||
// Second signup attempt with same email in new browser context
|
||||
// Navigate to signup page
|
||||
await newPage.goto("http://localhost:3000/signup");
|
||||
|
||||
// Wait for page to load
|
||||
getText("Create a new account");
|
||||
|
||||
// Fill form
|
||||
const emailInput = getField("Email");
|
||||
await emailInput.fill(testEmail);
|
||||
const passwordInput = newPage.locator("#password");
|
||||
await passwordInput.fill(testPassword);
|
||||
const confirmPasswordInput = newPage.locator("#confirmPassword");
|
||||
await confirmPasswordInput.fill(testPassword);
|
||||
|
||||
// Agree to terms and submit
|
||||
await getRole("checkbox").click();
|
||||
const signupButton = getButton("Sign up");
|
||||
await signupButton.click();
|
||||
await isVisible(getText("User with this email already exists"));
|
||||
} catch (_error) {
|
||||
} finally {
|
||||
// Clean up new browser context
|
||||
await newContext.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Duplicate email handling test failed:", error);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/AutoGPT Platform/);
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { test, expect } from "./coverage-fixture";
|
||||
import { setNestedProperty } from "../lib/utils";
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: "simple property assignment",
|
||||
path: "name",
|
||||
value: "John",
|
||||
expected: { name: "John" },
|
||||
},
|
||||
{
|
||||
name: "nested property with dot notation",
|
||||
path: "user.settings.theme",
|
||||
value: "dark",
|
||||
expected: { user: { settings: { theme: "dark" } } },
|
||||
},
|
||||
{
|
||||
name: "nested property with slash notation",
|
||||
path: "user/settings/language",
|
||||
value: "en",
|
||||
expected: { user: { settings: { language: "en" } } },
|
||||
},
|
||||
{
|
||||
name: "mixed dot and slash notation",
|
||||
path: "user.settings/preferences.color",
|
||||
value: "blue",
|
||||
expected: { user: { settings: { preferences: { color: "blue" } } } },
|
||||
},
|
||||
{
|
||||
name: "overwrite primitive with object",
|
||||
path: "user.details",
|
||||
value: { age: 30 },
|
||||
expected: { user: { details: { age: 30 } } },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, path, value, expected } of testCases) {
|
||||
test(name, () => {
|
||||
const obj = {};
|
||||
setNestedProperty(obj, path, value);
|
||||
expect(obj).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
test("should throw error for null object", () => {
|
||||
expect(() => {
|
||||
setNestedProperty(null, "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("should throw error for undefined object", () => {
|
||||
expect(() => {
|
||||
setNestedProperty(undefined, "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("should throw error for non-object target", () => {
|
||||
expect(() => {
|
||||
setNestedProperty("string", "test", "value");
|
||||
}).toThrow("Target must be a non-null object");
|
||||
});
|
||||
|
||||
test("should throw error for empty path", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "", "value");
|
||||
}).toThrow("Path must be a non-empty string");
|
||||
});
|
||||
|
||||
test("should throw error for __proto__ access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "__proto__.malicious", "attack");
|
||||
}).toThrow("Invalid property name: __proto__");
|
||||
});
|
||||
|
||||
test("should throw error for constructor access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "constructor.prototype.malicious", "attack");
|
||||
}).toThrow("Invalid property name: constructor");
|
||||
});
|
||||
|
||||
test("should throw error for prototype access", () => {
|
||||
expect(() => {
|
||||
setNestedProperty({}, "obj.prototype.malicious", "attack");
|
||||
}).toThrow("Invalid property name: prototype");
|
||||
});
|
||||
|
||||
test("secure implementation prevents prototype pollution", () => {
|
||||
const obj = {};
|
||||
expect(() => {
|
||||
setNestedProperty(obj, "__proto__.polluted", true);
|
||||
}).toThrow("Invalid property name: __proto__");
|
||||
|
||||
// Verify no pollution occurred
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
expect({}.polluted).toBeUndefined();
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { signupTestUser } from "./signup";
|
||||
import { getBrowser } from "./get-browser";
|
||||
|
||||
export interface TestUser {
|
||||
email: string;
|
||||
password: string;
|
||||
id?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface UserPool {
|
||||
users: TestUser[];
|
||||
createdAt: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export async function createTestUser(
|
||||
email?: string,
|
||||
password?: string,
|
||||
ignoreOnboarding: boolean = true,
|
||||
): Promise<TestUser> {
|
||||
const { faker } = await import("@faker-js/faker");
|
||||
const userEmail = email || faker.internet.email();
|
||||
const userPassword = password || faker.internet.password({ length: 12 });
|
||||
|
||||
try {
|
||||
const browser = await getBrowser();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Auto-accept cookies in test environment to prevent banner from appearing
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem(
|
||||
"autogpt_cookie_consent",
|
||||
JSON.stringify({
|
||||
hasConsented: true,
|
||||
timestamp: Date.now(),
|
||||
analytics: true,
|
||||
monitoring: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const testUser = await signupTestUser(
|
||||
page,
|
||||
userEmail,
|
||||
userPassword,
|
||||
ignoreOnboarding,
|
||||
false,
|
||||
);
|
||||
return testUser;
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating test user ${userEmail}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestUsers(count: number): Promise<TestUser[]> {
|
||||
console.log(`👥 Creating ${count} test users...`);
|
||||
|
||||
const users: TestUser[] = [];
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const user = await createTestUser();
|
||||
users.push(user);
|
||||
consecutiveFailures = 0; // Reset failure counter on success
|
||||
console.log(`✅ Created user ${i + 1}/${count}: ${user.email}`);
|
||||
|
||||
// Small delay to prevent overwhelming the system
|
||||
if (i < count - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (error) {
|
||||
consecutiveFailures++;
|
||||
console.error(`❌ Failed to create user ${i + 1}/${count}:`, error);
|
||||
|
||||
// If we have too many consecutive failures, stop trying
|
||||
if (consecutiveFailures >= 3) {
|
||||
console.error(
|
||||
`⚠️ Stopping after ${consecutiveFailures} consecutive failures`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add a longer delay after failure to let system recover
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Successfully created ${users.length}/${count} test users`);
|
||||
return users;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTestUser(): Promise<TestUser> {
|
||||
const userPool = await loadUserPool();
|
||||
if (!userPool) {
|
||||
throw new Error("User pool not found");
|
||||
}
|
||||
|
||||
if (userPool.users.length === 0) {
|
||||
throw new Error("No users available in the pool");
|
||||
}
|
||||
|
||||
// Return a random user from the pool
|
||||
const randomIndex = Math.floor(Math.random() * userPool.users.length);
|
||||
return userPool.users[randomIndex];
|
||||
}
|
||||
41
autogpt_platform/frontend/src/types/auth.test.ts
Normal file
41
autogpt_platform/frontend/src/types/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { signupFormSchema } from "./auth";
|
||||
|
||||
describe("signupFormSchema", () => {
|
||||
test("rejects invalid signup input", () => {
|
||||
const result = signupFormSchema.safeParse({
|
||||
email: "not-an-email",
|
||||
password: "short",
|
||||
confirmPassword: "different",
|
||||
agreeToTerms: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fieldErrors } = result.error.flatten();
|
||||
|
||||
expect(fieldErrors.email?.length).toBeGreaterThan(0);
|
||||
expect(fieldErrors.password).toContain(
|
||||
"Password must contain at least 12 characters",
|
||||
);
|
||||
expect(fieldErrors.confirmPassword).toContain("Passwords don't match");
|
||||
expect(fieldErrors.agreeToTerms).toContain(
|
||||
"You must agree to the Terms of Use and Privacy Policy",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a valid signup payload", () => {
|
||||
const result = signupFormSchema.safeParse({
|
||||
email: "valid@example.com",
|
||||
password: "validpassword123",
|
||||
confirmPassword: "validpassword123",
|
||||
agreeToTerms: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
exclude: [
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.stories.{ts,tsx}",
|
||||
"src/playwright/**",
|
||||
"src/tests/**",
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user