Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into feat/subscription-tier-billing

This commit is contained in:
majdyz
2026-04-15 00:00:23 +07:00
94 changed files with 6691 additions and 3961 deletions

View File

@@ -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

View File

@@ -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

View 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": []
}
}

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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

View File

@@ -193,3 +193,4 @@ services:
- copilot_executor
- websocket_server
- database_manager
- scheduler_server

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
},

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -55,6 +55,7 @@ export function NotificationForm({ preferences, user }: NotificationFormProps) {
</div>
<FormControl>
<Switch
aria-label="Agent Run Notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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": [

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});

View 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();
});
});

View File

@@ -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);
});

View 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();
});

View File

@@ -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");
});

View File

@@ -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 });
});

View File

@@ -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];
}

View File

@@ -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;

View File

@@ -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,
}),
},
],
},
],
};
}

View 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;

View File

@@ -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();
});

View File

@@ -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+)?$/);
});

View 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}`);
}
}

View File

@@ -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 });
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}
}
}

View File

@@ -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`);
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
});

View File

@@ -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);
});

View 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),
),
);
}

View File

@@ -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 };
}

View File

@@ -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...");

View File

@@ -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

View File

@@ -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 });
});

View File

@@ -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/);
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());

View File

@@ -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();
});
});

View File

@@ -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`),
);
});
});

View File

@@ -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\/.+/);
});
});

View File

@@ -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 ✅",
);
});
});

View File

@@ -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();
});

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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]);
}
});
});

View File

@@ -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();
});

View File

@@ -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);
});

View File

@@ -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);
}
}
});

View File

@@ -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");
});

View File

@@ -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);
}
});

View File

@@ -1,6 +0,0 @@
import { test, expect } from "./coverage-fixture";
test("has title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/AutoGPT Platform/);
});

View File

@@ -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();
});

View File

@@ -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];
}

View 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);
});
});

View File

@@ -16,6 +16,7 @@ export default defineConfig({
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.stories.{ts,tsx}",
"src/playwright/**",
"src/tests/**",
],
},