From b4cd00bea97f4dd68343a0aa94fd11aa4b1f3481 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Tue, 14 Apr 2026 22:19:32 +0700 Subject: [PATCH 1/3] dx(frontend): untrack auto-generated API client model files (#12778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `src/app/api/__generated__/` is listed in `.gitignore` but 4 model files were committed before that rule existed, so git kept tracking them and they showed up in every PR that touched the API schema. ## What Run `git rm --cached` on all 4 tracked files so the existing gitignore rule takes effect. No gitignore content changes needed — the rule was already correct. ## How The `check API types` CI job only diffs `openapi.json` against the backend's exported schema — it does not diff the generated TypeScript models. So removing these from tracking does not break any CI check. After this merges, `pnpm generate:api` output will be gitignored everywhere and future API-touching PRs won't include generated model diffs. --- .../models/blockOutputResponse.ts | 25 ------------- .../models/graphExecutionMeta.ts | 36 ------------------- .../models/suggestedPromptsResponse.ts | 15 -------- .../__generated__/models/suggestedTheme.ts | 15 -------- 4 files changed, 91 deletions(-) delete mode 100644 autogpt_platform/frontend/src/app/api/__generated__/models/blockOutputResponse.ts delete mode 100644 autogpt_platform/frontend/src/app/api/__generated__/models/graphExecutionMeta.ts delete mode 100644 autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts delete mode 100644 autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts diff --git a/autogpt_platform/frontend/src/app/api/__generated__/models/blockOutputResponse.ts b/autogpt_platform/frontend/src/app/api/__generated__/models/blockOutputResponse.ts deleted file mode 100644 index a25b1a04d3..0000000000 --- a/autogpt_platform/frontend/src/app/api/__generated__/models/blockOutputResponse.ts +++ /dev/null @@ -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; -} diff --git a/autogpt_platform/frontend/src/app/api/__generated__/models/graphExecutionMeta.ts b/autogpt_platform/frontend/src/app/api/__generated__/models/graphExecutionMeta.ts deleted file mode 100644 index c8bf7115ce..0000000000 --- a/autogpt_platform/frontend/src/app/api/__generated__/models/graphExecutionMeta.ts +++ /dev/null @@ -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; -} diff --git a/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts b/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts deleted file mode 100644 index 9f8b44c585..0000000000 --- a/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts +++ /dev/null @@ -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[]; -} diff --git a/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts b/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts deleted file mode 100644 index 5fec92e394..0000000000 --- a/autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts +++ /dev/null @@ -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[]; -} From 7240dd4fb108b66c0dcbdf460fe7d6b5e5dba9ef Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Tue, 14 Apr 2026 22:20:50 +0700 Subject: [PATCH 2/3] feat(platform/admin): enhance cost dashboard with token breakdown and averages (#12757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Token breakdown in provider table**: Added separate Input Tokens and Output Tokens columns to the By Provider table, making it easy to see whether costs are driven by large contexts (input) or verbose responses/thinking (output) - **New summary cards (8 total)**: Added Avg Cost/Request, Avg Input Tokens, Avg Output Tokens, and Total Tokens (in/out split) cards plus P50/P75/P95/P99 cost percentile cards at the top of the dashboard for at-a-glance cost analysis - **Cost distribution histogram**: Added a cost distribution section showing request count across configurable price buckets ($0–0.50, $0.50–1, $1–2, $2–5, $5–10, $10+) - **Per-user avg cost**: Added Avg Cost/Req column to the By User table to identify users with unusually expensive requests - **Backend aggregations**: Extended `PlatformCostDashboard` model with `total_input_tokens`, `total_output_tokens`, `avg_input_tokens_per_request`, `avg_output_tokens_per_request`, `avg_cost_microdollars_per_request`, `cost_p50/p75/p95/p99_microdollars`, and `cost_buckets` fields - **Correct denominators**: Avg cost uses cost-bearing requests only; avg token stats use token-bearing requests only — no artificial dilution from non-cost/non-token rows ## Test plan - [x] Verify the admin cost dashboard loads without errors at `/admin/platform-costs` - [x] Check that the new summary cards display correct values - [x] Verify Input/Output Tokens columns appear in the By Provider table - [x] Verify Avg Cost/Req column appears in the By User table - [x] Confirm existing functionality (filters, export, rate overrides) still works - [x] Verify backward compatibility — new fields have defaults so old API responses still work --- .../backend/backend/data/platform_cost.py | 295 ++++++++++++++++- .../backend/data/platform_cost_test.py | 302 +++++++++++++++++- .../__tests__/PlatformCostContent.test.tsx | 111 ++++++- .../components/PlatformCostContent.tsx | 134 ++++++-- .../components/ProviderTable.tsx | 27 +- .../platform-costs/components/UserTable.tsx | 20 +- .../frontend/src/app/api/openapi.json | 69 +++- 7 files changed, 894 insertions(+), 64 deletions(-) diff --git a/autogpt_platform/backend/backend/data/platform_cost.py b/autogpt_platform/backend/backend/data/platform_cost.py index ec27572058..aa539bc66b 100644 --- a/autogpt_platform/backend/backend/data/platform_cost.py +++ b/autogpt_platform/backend/backend/data/platform_cost.py @@ -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, ) diff --git a/autogpt_platform/backend/backend/data/platform_cost_test.py b/autogpt_platform/backend/backend/data/platform_cost_test.py index 4a2372628b..ad15fb425b 100644 --- a/autogpt_platform/backend/backend/data/platform_cost_test.py +++ b/autogpt_platform/backend/backend/data/platform_cost_test.py @@ -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( diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/__tests__/PlatformCostContent.test.tsx b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/__tests__/PlatformCostContent.test.tsx index 5944e94ea7..bde8507b37 100644 --- a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/__tests__/PlatformCostContent.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/__tests__/PlatformCostContent.test.tsx @@ -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 () => { diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/PlatformCostContent.tsx b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/PlatformCostContent.tsx index 749a2136a3..ce0329af19 100644 --- a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/PlatformCostContent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/PlatformCostContent.tsx @@ -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 (
@@ -204,37 +275,54 @@ export function PlatformCostContent({ searchParams }: Props) { {loading ? (
-
- {[...Array(4)].map((_, i) => ( +
+ {/* 12 skeleton placeholders — one per summary card */} + {Array.from({ length: 12 }, (_, i) => ( ))}
+
) : ( <> {dashboard && ( -
- - - - -
+ <> +
+ {summaryCards.map((card) => ( + + ))} +
+ + {dashboard.cost_buckets && dashboard.cost_buckets.length > 0 && ( +
+

+ Cost Distribution by Bucket +

+
+ {dashboard.cost_buckets.map((b: CostBucket) => ( +
+ + {b.bucket} + + + {b.count.toLocaleString()} + +
+ ))} +
+
+ )} + )}
Usage + + Input Tokens + + + Output Tokens + Requests @@ -74,6 +89,16 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) { {trackingValue(row)} + + {row.total_input_tokens > 0 + ? formatTokens(row.total_input_tokens) + : "-"} + + + {row.total_output_tokens > 0 + ? formatTokens(row.total_output_tokens) + : "-"} + {row.request_count.toLocaleString()} @@ -124,7 +149,7 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) { {data.length === 0 && ( No cost data yet diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/UserTable.tsx b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/UserTable.tsx index c2ee70ce72..aa14ca175c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/UserTable.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/UserTable.tsx @@ -27,10 +27,7 @@ function UserTable({ data }: Props) { Output Tokens - Cache Read - - - Cache Write + Avg Cost / Req @@ -61,13 +58,12 @@ function UserTable({ data }: Props) { {formatTokens(row.total_output_tokens)} - {(row.total_cache_read_tokens ?? 0) > 0 - ? formatTokens(row.total_cache_read_tokens ?? 0) - : "-"} - - - {(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), + ) : "-"} @@ -75,7 +71,7 @@ function UserTable({ data }: Props) { {data.length === 0 && ( No cost data yet diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 43f14a13fd..732ef569d9 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -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": [ @@ -15585,7 +15645,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": [ From b06648de8cbf095231523889434b6b8c6bedb4e3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:24:11 +0530 Subject: [PATCH 3/3] ci(frontend): add Playwright PR smoke suite with seeded QA accounts (#12682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Why / What / How This PR simplifies frontend PR validation to one Playwright E2E suite, moves redundant page-level browser coverage into Vitest integration tests, and switches Playwright auth to deterministic seeded QA accounts. It also folds in the follow-up fixes that came out of review and CI: lint cleanup, CodeQL feedback, PR-local type regressions, and the flaky Library run helper. The approach is: - keep Playwright focused on real browser and cross-page flows that integration tests cannot prove well - keep page-level render and mocked API behavior in Vitest - remove the old PR-vs-full Playwright split from CI and run one deterministic PR suite instead - seed reusable auth states for fixed QA users so the browser suite is less flaky and faster to bootstrap ### Changes đŸ—ī¸ - Removed the workflow indirection that selected different Playwright suites for PRs vs other events - Standardized frontend CI on a single command: `pnpm test:e2e:no-build` - Consolidated the PR-gating Playwright suite around these happy-path specs: - `auth-happy-path.spec.ts` - `settings-happy-path.spec.ts` - `api-keys-happy-path.spec.ts` - `builder-happy-path.spec.ts` - `library-happy-path.spec.ts` - `marketplace-happy-path.spec.ts` - `publish-happy-path.spec.ts` - `copilot-happy-path.spec.ts` - Added the missing browser-only confidence checks to the PR suite: - settings persistence across reload and re-login - API key create, copy, and revoke - schedule `Run now` from Library - activity dropdown visibility for a real run - creator dashboard verification after publish submission - Increased Playwright CI workers from `6` to `8` - Migrated redundant page-level browser coverage into Vitest integration/unit tests where appropriate, including marketplace, profile, settings, API keys, signup behavior, agent dashboard row behavior, agent activity, and utility/auth helpers - Seeded deterministic Playwright QA users in `backend/test/e2e_test_data.py` and reused auth states from `frontend/src/tests/credentials/` - Fixed CodeQL insecure randomness feedback by replacing insecure randomness in test auth utilities - Fixed frontend lint issues in marketplace image rendering - Fixed PR-local type regressions introduced during test migration - Stabilized the Library E2E run helper to support the current Library action states: `Setup your task`, `New task`, `Rerun task`, and `Run now` - Removed obsolete Playwright specs and the temporary migration planning doc once the consolidation was complete - Reverted unintended non-test backend source changes; only backend test fixture changes remain in scope ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm lint` - [x] `pnpm types` - [x] `pnpm test:unit` - [x] `pnpm exec playwright test --list` - [x] `pnpm test:e2e:no-build` locally - [ ] PR CI green after the latest push #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) Notes: - Current local Playwright run on this branch: `28 passed`, `0 flaky`, `0 retries`, `3m 25s`. - Latest Codecov report on this PR showed overall coverage `63.14% -> 63.61%` (`+0.47%`), with frontend coverage up `+2.32%` and frontend E2E coverage up `+2.10%`. - The backend change in this PR is limited to deterministic E2E test data setup in `backend/test/e2e_test_data.py`. - Playwright retries remain enabled in CI; this branch does not add fail-on-flaky behavior. --------- Co-authored-by: Zamil Majdy Co-authored-by: Zamil Majdy --- .claude/skills/write-frontend-tests/SKILL.md | 11 +- .github/workflows/platform-fullstack-ci.yml | 13 +- .../backend/agents/calculator-agent.json | 166 ++ .../backend/test/e2e_test_data.py | 312 +++- autogpt_platform/docker-compose.platform.yml | 33 +- autogpt_platform/docker-compose.yml | 1 + autogpt_platform/frontend/README.md | 6 +- autogpt_platform/frontend/TESTING.md | 32 +- autogpt_platform/frontend/package.json | 8 +- .../frontend/playwright.config.ts | 54 +- .../MainAgentPage/__tests__/main.test.tsx | 96 ++ .../__tests__/main.test.tsx | 69 +- .../MainCreatorPage/__tests__/main.test.tsx | 57 + .../profile/(user)/__tests__/page.test.tsx | 83 + .../(user)/api-keys/__tests__/page.test.tsx | 138 ++ .../__tests__/AgentTableRow.test.tsx | 76 + .../(user)/settings/__tests__/page.test.tsx | 147 ++ .../EmailForm/__tests__/EmailForm.test.tsx | 97 ++ .../NotificationForm/NotificationForm.tsx | 1 + .../(platform)/signup/__tests__/page.test.tsx | 73 + .../__tests__/ProfileInfoForm.test.tsx | 94 ++ .../__tests__/AgentActivityDropdown.test.tsx | 76 + .../frontend/src/lib/utils.test.ts | 97 ++ .../playwright/api-keys-happy-path.spec.ts | 100 ++ .../assets/testing_agent.json | 0 .../src/playwright/auth-happy-path.spec.ts | 158 ++ .../src/playwright/builder-happy-path.spec.ts | 83 + .../src/playwright/copilot-happy-path.spec.ts | 44 + .../{tests => playwright}/coverage-fixture.ts | 0 .../src/playwright/credentials/accounts.ts | 85 ++ .../src/playwright/credentials/index.ts | 27 + .../playwright/credentials/storage-state.ts | 23 + .../frontend/src/playwright/global-setup.ts | 49 + .../src/playwright/library-happy-path.spec.ts | 559 +++++++ .../playwright/marketplace-happy-path.spec.ts | 48 + .../{tests => playwright}/pages/base.page.ts | 0 .../src/playwright/pages/build.page.ts | 642 ++++++++ .../src/playwright/pages/copilot.page.ts | 44 + .../pages/header.page.ts | 0 .../src/playwright/pages/library.page.ts | 1342 +++++++++++++++++ .../src/playwright/pages/login.page.ts | 123 ++ .../src/playwright/pages/marketplace.page.ts | 294 ++++ .../pages/navbar.page.ts | 0 .../pages/profile-form.page.ts | 0 .../pages/profile.page.ts | 0 .../src/playwright/pages/settings.page.ts | 29 + .../src/playwright/publish-happy-path.spec.ts | 77 + .../playwright/settings-happy-path.spec.ts | 75 + .../{tests => playwright}/utils/assertion.ts | 0 .../frontend/src/playwright/utils/auth.ts | 284 ++++ .../utils/get-browser.ts | 0 .../{tests => playwright}/utils/onboarding.ts | 29 +- .../{tests => playwright}/utils/selectors.ts | 0 .../src/{tests => playwright}/utils/signin.ts | 0 .../src/{tests => playwright}/utils/signup.ts | 4 +- autogpt_platform/frontend/src/tests/AGENTS.md | 34 +- .../frontend/src/tests/agent-activity.spec.ts | 96 -- .../src/tests/agent-dashboard.spec.ts | 260 ---- .../frontend/src/tests/api-keys.spec.ts | 65 - .../frontend/src/tests/build.spec.ts | 134 -- .../frontend/src/tests/credentials/index.ts | 28 - .../frontend/src/tests/global-setup.ts | 52 - .../src/tests/integrations/vitest.setup.tsx | 6 +- .../frontend/src/tests/library.spec.ts | 250 --- .../src/tests/marketplace-agent.spec.ts | 120 -- .../src/tests/marketplace-creator.spec.ts | 82 - .../frontend/src/tests/marketplace.spec.ts | 168 --- .../frontend/src/tests/onboarding.spec.ts | 114 -- .../frontend/src/tests/pages/build.page.ts | 310 ---- .../frontend/src/tests/pages/library.page.ts | 559 ------- .../frontend/src/tests/pages/login.page.ts | 102 -- .../src/tests/pages/marketplace.page.ts | 143 -- .../frontend/src/tests/profile-form.spec.ts | 109 -- .../frontend/src/tests/profile.spec.ts | 47 - .../frontend/src/tests/publish-agent.spec.ts | 276 ---- .../frontend/src/tests/settings.spec.ts | 144 -- .../frontend/src/tests/signin.spec.ts | 199 --- .../frontend/src/tests/signup.spec.ts | 126 -- .../frontend/src/tests/title.spec.ts | 6 - .../frontend/src/tests/util.spec.ts | 97 -- .../frontend/src/tests/utils/auth.ts | 175 --- .../frontend/src/types/auth.test.ts | 41 + autogpt_platform/frontend/vitest.config.mts | 1 + 83 files changed, 5797 insertions(+), 3806 deletions(-) create mode 100644 autogpt_platform/backend/agents/calculator-agent.json create mode 100644 autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/__tests__/main.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/components/MainCreatorPage/__tests__/main.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/profile/(user)/__tests__/page.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/__tests__/page.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/profile/(user)/dashboard/components/AgentTableRow/__tests__/AgentTableRow.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/__tests__/page.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/EmailForm/__tests__/EmailForm.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/signup/__tests__/page.test.tsx create mode 100644 autogpt_platform/frontend/src/components/__legacy__/__tests__/ProfileInfoForm.test.tsx create mode 100644 autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/__tests__/AgentActivityDropdown.test.tsx create mode 100644 autogpt_platform/frontend/src/lib/utils.test.ts create mode 100644 autogpt_platform/frontend/src/playwright/api-keys-happy-path.spec.ts rename autogpt_platform/frontend/src/{tests => playwright}/assets/testing_agent.json (100%) create mode 100644 autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts create mode 100644 autogpt_platform/frontend/src/playwright/builder-happy-path.spec.ts create mode 100644 autogpt_platform/frontend/src/playwright/copilot-happy-path.spec.ts rename autogpt_platform/frontend/src/{tests => playwright}/coverage-fixture.ts (100%) create mode 100644 autogpt_platform/frontend/src/playwright/credentials/accounts.ts create mode 100644 autogpt_platform/frontend/src/playwright/credentials/index.ts create mode 100644 autogpt_platform/frontend/src/playwright/credentials/storage-state.ts create mode 100644 autogpt_platform/frontend/src/playwright/global-setup.ts create mode 100644 autogpt_platform/frontend/src/playwright/library-happy-path.spec.ts create mode 100644 autogpt_platform/frontend/src/playwright/marketplace-happy-path.spec.ts rename autogpt_platform/frontend/src/{tests => playwright}/pages/base.page.ts (100%) create mode 100644 autogpt_platform/frontend/src/playwright/pages/build.page.ts create mode 100644 autogpt_platform/frontend/src/playwright/pages/copilot.page.ts rename autogpt_platform/frontend/src/{tests => playwright}/pages/header.page.ts (100%) create mode 100644 autogpt_platform/frontend/src/playwright/pages/library.page.ts create mode 100644 autogpt_platform/frontend/src/playwright/pages/login.page.ts create mode 100644 autogpt_platform/frontend/src/playwright/pages/marketplace.page.ts rename autogpt_platform/frontend/src/{tests => playwright}/pages/navbar.page.ts (100%) rename autogpt_platform/frontend/src/{tests => playwright}/pages/profile-form.page.ts (100%) rename autogpt_platform/frontend/src/{tests => playwright}/pages/profile.page.ts (100%) create mode 100644 autogpt_platform/frontend/src/playwright/pages/settings.page.ts create mode 100644 autogpt_platform/frontend/src/playwright/publish-happy-path.spec.ts create mode 100644 autogpt_platform/frontend/src/playwright/settings-happy-path.spec.ts rename autogpt_platform/frontend/src/{tests => playwright}/utils/assertion.ts (100%) create mode 100644 autogpt_platform/frontend/src/playwright/utils/auth.ts rename autogpt_platform/frontend/src/{tests => playwright}/utils/get-browser.ts (100%) rename autogpt_platform/frontend/src/{tests => playwright}/utils/onboarding.ts (70%) rename autogpt_platform/frontend/src/{tests => playwright}/utils/selectors.ts (100%) rename autogpt_platform/frontend/src/{tests => playwright}/utils/signin.ts (100%) rename autogpt_platform/frontend/src/{tests => playwright}/utils/signup.ts (98%) delete mode 100644 autogpt_platform/frontend/src/tests/agent-activity.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/agent-dashboard.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/api-keys.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/build.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/credentials/index.ts delete mode 100644 autogpt_platform/frontend/src/tests/global-setup.ts delete mode 100644 autogpt_platform/frontend/src/tests/library.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/marketplace-agent.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/marketplace.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/onboarding.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/pages/build.page.ts delete mode 100644 autogpt_platform/frontend/src/tests/pages/library.page.ts delete mode 100644 autogpt_platform/frontend/src/tests/pages/login.page.ts delete mode 100644 autogpt_platform/frontend/src/tests/pages/marketplace.page.ts delete mode 100644 autogpt_platform/frontend/src/tests/profile-form.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/profile.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/publish-agent.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/settings.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/signin.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/signup.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/title.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/util.spec.ts delete mode 100644 autogpt_platform/frontend/src/tests/utils/auth.ts create mode 100644 autogpt_platform/frontend/src/types/auth.test.ts diff --git a/.claude/skills/write-frontend-tests/SKILL.md b/.claude/skills/write-frontend-tests/SKILL.md index 177ce64a68..389de2023b 100644 --- a/.claude/skills/write-frontend-tests/SKILL.md +++ b/.claude/skills/write-frontend-tests/SKILL.md @@ -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 diff --git a/.github/workflows/platform-fullstack-ci.yml b/.github/workflows/platform-fullstack-ci.yml index 5020f8aa2e..605c13c38b 100644 --- a/.github/workflows/platform-fullstack-ci.yml +++ b/.github/workflows/platform-fullstack-ci.yml @@ -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 diff --git a/autogpt_platform/backend/agents/calculator-agent.json b/autogpt_platform/backend/agents/calculator-agent.json new file mode 100644 index 0000000000..9851b1496b --- /dev/null +++ b/autogpt_platform/backend/agents/calculator-agent.json @@ -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": [] + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/test/e2e_test_data.py b/autogpt_platform/backend/test/e2e_test_data.py index add6013893..974b60fb1a 100644 --- a/autogpt_platform/backend/test/e2e_test_data.py +++ b/autogpt_platform/backend/test/e2e_test_data.py @@ -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: diff --git a/autogpt_platform/docker-compose.platform.yml b/autogpt_platform/docker-compose.platform.yml index 29ab586a47..1b3ff8338f 100644 --- a/autogpt_platform/docker-compose.platform.yml +++ b/autogpt_platform/docker-compose.platform.yml @@ -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 diff --git a/autogpt_platform/docker-compose.yml b/autogpt_platform/docker-compose.yml index ef9c738834..f7b4b105fc 100644 --- a/autogpt_platform/docker-compose.yml +++ b/autogpt_platform/docker-compose.yml @@ -193,3 +193,4 @@ services: - copilot_executor - websocket_server - database_manager + - scheduler_server diff --git a/autogpt_platform/frontend/README.md b/autogpt_platform/frontend/README.md index abea810fd2..aec05dfbbb 100644 --- a/autogpt_platform/frontend/README.md +++ b/autogpt_platform/frontend/README.md @@ -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 diff --git a/autogpt_platform/frontend/TESTING.md b/autogpt_platform/frontend/TESTING.md index 0b95f8eaab..ee8ed5d9cf 100644 --- a/autogpt_platform/frontend/TESTING.md +++ b/autogpt_platform/frontend/TESTING.md @@ -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 diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 00e9e6fc8a..4661ab2050 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -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", diff --git a/autogpt_platform/frontend/playwright.config.ts b/autogpt_platform/frontend/playwright.config.ts index bf3c19845f..0805443035 100644 --- a/autogpt_platform/frontend/playwright.config.ts +++ b/autogpt_platform/frontend/playwright.config.ts @@ -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, }, diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/__tests__/main.test.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/__tests__/main.test.tsx new file mode 100644 index 0000000000..f9a9d76f12 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/__tests__/main.test.tsx @@ -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( + , + ); + + 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(); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePage/__tests__/main.test.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePage/__tests__/main.test.tsx index bee227a7af..0e902abe44 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePage/__tests__/main.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePage/__tests__/main.test.tsx @@ -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(); - 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(); + + 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(); + }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/components/MainCreatorPage/__tests__/main.test.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/components/MainCreatorPage/__tests__/main.test.tsx new file mode 100644 index 0000000000..b3224fa3ce --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/components/MainCreatorPage/__tests__/main.test.tsx @@ -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(); + + 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); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/__tests__/page.test.tsx new file mode 100644 index 0000000000..c6cd516c26 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/__tests__/page.test.tsx @@ -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(); + + 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"); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/__tests__/page.test.tsx new file mode 100644 index 0000000000..404957e4c0 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/__tests__/page.test.tsx @@ -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(); + + 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(); + + 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"); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/dashboard/components/AgentTableRow/__tests__/AgentTableRow.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/dashboard/components/AgentTableRow/__tests__/AgentTableRow.test.tsx new file mode 100644 index 0000000000..04e1d4ad1e --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/dashboard/components/AgentTableRow/__tests__/AgentTableRow.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/__tests__/page.test.tsx new file mode 100644 index 0000000000..75c706dbcb --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/__tests__/page.test.tsx @@ -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(); + + 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; + } + | 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; + }; + + 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(); + + 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); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/EmailForm/__tests__/EmailForm.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/EmailForm/__tests__/EmailForm.test.tsx new file mode 100644 index 0000000000..fb7e4d397a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/EmailForm/__tests__/EmailForm.test.tsx @@ -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(); + + 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(); + + expect( + ( + screen.getByRole("button", { + name: "Update email", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/NotificationForm/NotificationForm.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/NotificationForm/NotificationForm.tsx index 38473234ab..8b85488cf5 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/NotificationForm/NotificationForm.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/NotificationForm/NotificationForm.tsx @@ -55,6 +55,7 @@ export function NotificationForm({ preferences, user }: NotificationFormProps) {
diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(platform)/signup/__tests__/page.test.tsx new file mode 100644 index 0000000000..4ac1e3dc50 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/signup/__tests__/page.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/autogpt_platform/frontend/src/components/__legacy__/__tests__/ProfileInfoForm.test.tsx b/autogpt_platform/frontend/src/components/__legacy__/__tests__/ProfileInfoForm.test.tsx new file mode 100644 index 0000000000..3ee732912c --- /dev/null +++ b/autogpt_platform/frontend/src/components/__legacy__/__tests__/ProfileInfoForm.test.tsx @@ -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 { + 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(); + 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 | null = null; + + server.use( + getPostV2UpdateUserProfileMockHandler200(async ({ request }) => { + receivedBody = (await request.json()) as Record; + return makeProfile({ name: receivedBody?.name as string }); + }), + ); + + render(); + + 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(); + + 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); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/__tests__/AgentActivityDropdown.test.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/__tests__/AgentActivityDropdown.test.tsx new file mode 100644 index 0000000000..5c45af03f4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/__tests__/AgentActivityDropdown.test.tsx @@ -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 { + 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(); + + 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(); + + expect(screen.getByTestId("agent-activity-dropdown")).toBeDefined(); + expect(await screen.findByText("Test Agent")).toBeDefined(); + }); +}); diff --git a/autogpt_platform/frontend/src/lib/utils.test.ts b/autogpt_platform/frontend/src/lib/utils.test.ts new file mode 100644 index 0000000000..62742ac574 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils.test.ts @@ -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(); + }); +}); diff --git a/autogpt_platform/frontend/src/playwright/api-keys-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/api-keys-happy-path.spec.ts new file mode 100644 index 0000000000..9d0cbf8afc --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/api-keys-happy-path.spec.ts @@ -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); +}); diff --git a/autogpt_platform/frontend/src/tests/assets/testing_agent.json b/autogpt_platform/frontend/src/playwright/assets/testing_agent.json similarity index 100% rename from autogpt_platform/frontend/src/tests/assets/testing_agent.json rename to autogpt_platform/frontend/src/playwright/assets/testing_agent.json diff --git a/autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts new file mode 100644 index 0000000000..a7872cb706 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/auth-happy-path.spec.ts @@ -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(); +}); diff --git a/autogpt_platform/frontend/src/playwright/builder-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/builder-happy-path.spec.ts new file mode 100644 index 0000000000..b6c2f8d8c2 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/builder-happy-path.spec.ts @@ -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"); +}); diff --git a/autogpt_platform/frontend/src/playwright/copilot-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/copilot-happy-path.spec.ts new file mode 100644 index 0000000000..5af1fc7a86 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/copilot-happy-path.spec.ts @@ -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 }); +}); diff --git a/autogpt_platform/frontend/src/tests/coverage-fixture.ts b/autogpt_platform/frontend/src/playwright/coverage-fixture.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/coverage-fixture.ts rename to autogpt_platform/frontend/src/playwright/coverage-fixture.ts diff --git a/autogpt_platform/frontend/src/playwright/credentials/accounts.ts b/autogpt_platform/frontend/src/playwright/credentials/accounts.ts new file mode 100644 index 0000000000..f0fef0cfea --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/credentials/accounts.ts @@ -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]; +} diff --git a/autogpt_platform/frontend/src/playwright/credentials/index.ts b/autogpt_platform/frontend/src/playwright/credentials/index.ts new file mode 100644 index 0000000000..cefa3931cb --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/credentials/index.ts @@ -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; diff --git a/autogpt_platform/frontend/src/playwright/credentials/storage-state.ts b/autogpt_platform/frontend/src/playwright/credentials/storage-state.ts new file mode 100644 index 0000000000..1dbaaa1616 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/credentials/storage-state.ts @@ -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, + }), + }, + ], + }, + ], + }; +} diff --git a/autogpt_platform/frontend/src/playwright/global-setup.ts b/autogpt_platform/frontend/src/playwright/global-setup.ts new file mode 100644 index 0000000000..90270d32a0 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/global-setup.ts @@ -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; diff --git a/autogpt_platform/frontend/src/playwright/library-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/library-happy-path.spec.ts new file mode 100644 index 0000000000..f7ed0e796c --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/library-happy-path.spec.ts @@ -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; + 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 { + 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 { + 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(); +}); diff --git a/autogpt_platform/frontend/src/playwright/marketplace-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/marketplace-happy-path.spec.ts new file mode 100644 index 0000000000..f81386ea40 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/marketplace-happy-path.spec.ts @@ -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+)?$/); +}); diff --git a/autogpt_platform/frontend/src/tests/pages/base.page.ts b/autogpt_platform/frontend/src/playwright/pages/base.page.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/pages/base.page.ts rename to autogpt_platform/frontend/src/playwright/pages/base.page.ts diff --git a/autogpt_platform/frontend/src/playwright/pages/build.page.ts b/autogpt_platform/frontend/src/playwright/pages/build.page.ts new file mode 100644 index 0000000000..7c3649201f --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/build.page.ts @@ -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 { + await this.page.goto("/build"); + await this.page.waitForLoadState("domcontentloaded"); + } + + async isLoaded(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return await this.getNodeLocator().count(); + } + + async waitForNodeOnCanvas(expectedCount?: number): Promise { + 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 { + const node = this.getNodeLocator(index); + await node.click(); + } + + async selectAllNodes(): Promise { + 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 { + await this.page.keyboard.press("Backspace"); + } + + // --- Connections (Edges) --- + + async connectNodes( + sourceNodeIndex: number, + targetNodeIndex: number, + ): Promise { + // 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 { + return await this.page.locator(".react-flow__edge").count(); + } + + // --- Save --- + + async saveAgent( + name: string = "Test Agent", + description: string = "", + ): Promise { + 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 { + await expect(this.page).toHaveURL(/flowID=/, { timeout: 15000 }); + } + + async waitForSaveButton(): Promise { + await this.page.waitForSelector( + '[data-testid="save-control-save-button"]:not([disabled])', + { timeout: 10000 }, + ); + } + + // --- Run --- + + async isRunButtonEnabled(): Promise { + const runButton = this.page.locator('[data-id="run-graph-button"]'); + return await runButton.isEnabled(); + } + + async clickRunButton(): Promise { + // 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 { + const btn = this.page.locator('[data-id="undo-button"]'); + return !(await btn.isDisabled()); + } + + async isRedoEnabled(): Promise { + const btn = this.page.locator('[data-id="redo-button"]'); + return !(await btn.isDisabled()); + } + + async clickUndo(): Promise { + await this.page.locator('[data-id="undo-button"]').click(); + } + + async clickRedo(): Promise { + await this.page.locator('[data-id="redo-button"]').click(); + } + + // --- Copy / Paste --- + + async copyViaKeyboard(): Promise { + const isMac = process.platform === "darwin"; + await this.page.keyboard.press(isMac ? "Meta+c" : "Control+c"); + } + + async pasteViaKeyboard(): Promise { + 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 { + const node = this.getNodeLocator(nodeIndex); + const input = node.getByPlaceholder(placeholder); + await input.fill(value); + } + + async clickCanvas(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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

title has id="-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 { + // 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 { + 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 { + // 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 { + 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 { + 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 { + return this.page.evaluate(() => + window.localStorage.getItem("shepherd-tour"), + ); + } + + async createScheduleForSavedAgent(agentName: string): Promise { + 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}`); + } +} diff --git a/autogpt_platform/frontend/src/playwright/pages/copilot.page.ts b/autogpt_platform/frontend/src/playwright/pages/copilot.page.ts new file mode 100644 index 0000000000..d67e20ef6e --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/copilot.page.ts @@ -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 { + const url = sessionId ? `/copilot?sessionId=${sessionId}` : "/copilot"; + await this.page.goto(url); + await expect(this.page).toHaveURL(/\/copilot/); + await this.dismissNotificationPrompt(); + } + + async dismissNotificationPrompt(): Promise { + // 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 { + 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 { + await expect(this.getChatInput()).toBeVisible({ timeout: 15000 }); + } +} diff --git a/autogpt_platform/frontend/src/tests/pages/header.page.ts b/autogpt_platform/frontend/src/playwright/pages/header.page.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/pages/header.page.ts rename to autogpt_platform/frontend/src/playwright/pages/header.page.ts diff --git a/autogpt_platform/frontend/src/playwright/pages/library.page.ts b/autogpt_platform/frontend/src/playwright/pages/library.page.ts new file mode 100644 index 0000000000..85c3f3978a --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/library.page.ts @@ -0,0 +1,1342 @@ +import { expect, Locator, Page } from "@playwright/test"; +import { getSeededTestUser } from "../credentials/accounts"; +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 { + 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 { + await this.page.goto("/library"); + await this.isLoaded(); + } + + async openSavedAgent(agentName: string): Promise { + await openSavedAgentInLibrary(this.page, agentName); + } + + async waitForRunToComplete(timeout = 45000): Promise { + await waitForRunToComplete(this.page, timeout); + } + + async getRunStatus(): Promise { + return getRunStatus(this.page); + } + + async assertRunProducedOutput(timeout = 15000): Promise { + await assertRunProducedOutput(this.page, timeout); + } + + async assertRunOutputValue( + outputName: string, + expectedValue: RegExp | string, + timeout = 15000, + ): Promise { + await assertRunOutputValue(this.page, outputName, expectedValue, timeout); + } + + async assertFirstRunOutputValue( + expectedValue: RegExp | string, + timeout = 15000, + ): Promise { + await assertRunOutputContainsText(this.page, expectedValue, timeout); + } + + async clickExportAgent(): Promise { + await clickExportAgent(this.page); + } + + async getAgentCount(): Promise { + 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 { + const { getId } = getSelectors(this.page); + const agentCards = await getId("library-agent-card").all(); + return agentCards.length; + } + + async searchAgents(searchTerm: string): Promise { + console.log(`searching for agents with term: ${searchTerm}`); + const { getRole } = getSelectors(this.page); + const searchInput = getRole("textbox", "Search agents"); + await searchInput.fill(searchTerm); + await expect(searchInput).toHaveValue(searchTerm); + } + + async clearSearch(): Promise { + console.log(`clearing search`); + // Look for the clear button (X icon) + const clearButton = this.page.locator(".lucide.lucide-x"); + const searchInput = this.page.getByRole("textbox", { + name: "Search agents", + }); + if (await clearButton.isVisible()) { + await clearButton.click(); + } else { + // If no clear button, clear the search input directly + await searchInput.fill(""); + } + await expect(searchInput).toHaveValue(""); + } + + async selectSortOption( + page: Page, + sortOption: "Creation Date" | "Last Modified", + ): Promise { + const { getRole } = getSelectors(page); + await getRole("combobox").click(); + + await getRole("option", sortOption).click(); + } + + async getCurrentSortOption(): Promise { + 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 { + 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 { + await this.page.getByRole("button", { name: "Close" }).click(); + + await this.page.getByRole("dialog", { name: "Import" }).waitFor({ + state: "hidden", + timeout: 5_000, + }); + } + + async isUploadDialogVisible(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const { getId } = getSelectors(this.page); + await expect + .poll( + async () => { + const [agentCardVisible, agentsCountVisible] = await Promise.all([ + getId("library-agent-card") + .first() + .isVisible() + .catch(() => false), + getId("agents-count") + .isVisible() + .catch(() => false), + ]); + + return agentCardVisible || agentsCountVisible; + }, + { timeout: 10_000 }, + ) + .toBe(true); + } + + async getSearchValue(): Promise { + 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 { + const { getText } = getSelectors(this.page); + const noAgentsText = getText("0 agents"); + return noAgentsText.isVisible(); + } + + async scrollToBottom(): Promise { + console.log(`scrolling to bottom to trigger pagination`); + await this.page.keyboard.press("End"); + } + + async scrollDown(): Promise { + console.log(`scrolling down to trigger pagination`); + await this.page.keyboard.press("PageDown"); + } + + // Returns true if more agents loaded, false if we're on the last page. + // Callers must distinguish these cases so a broken pagination pipeline + // doesn't quietly look like "we reached the end". + async scrollToLoadMore(): Promise { + const initialCount = await this.getAgentCountByListLength(); + console.log(`Initial agent count (DOM cards): ${initialCount}`); + + await this.scrollToBottom(); + + try { + await this.page.waitForFunction( + (prevCount) => + document.querySelectorAll('[data-testid="library-agent-card"]') + .length > prevCount, + initialCount, + { timeout: 10000 }, + ); + return true; + } catch { + // No new cards — caller should verify this is actually the last page + // (e.g., by comparing against `getAgentCount()`), not a broken fetch. + return false; + } + } + + 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 { + 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 { + // Wait until the agent count header stops changing. Poll every 500ms + // and declare stable after two consecutive equal reads, capped at 10s. + // The previous implementation had no delay between reads and so hit + // "stable" instantly — effectively a no-op. + const deadline = Date.now() + 10000; + let previousCount = -1; + let stableChecks = 0; + + while (Date.now() < deadline && stableChecks < 2) { + const currentCount = await this.getAgentCount(); + if (currentCount === previousCount) { + stableChecks += 1; + } else { + stableChecks = 0; + previousCount = currentCount; + } + await this.page.waitForTimeout(500); + } + } + + async scrollAndWaitForNewAgents(): Promise { + 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 { + 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 { + await getLibraryTab(page).click(); + await page.waitForURL(/.*\/library/); +} + +export async function clickFirstAgent(page: Page): Promise { + const firstAgent = getAgentCards(page).first(); + await firstAgent.click(); +} + +export async function navigateToAgentByName( + page: Page, + agentName: string, +): Promise { + 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 { + const setupTaskButton = page.getByRole("button", { + name: /Setup your task/i, + }); + const newTaskButton = page.getByRole("button", { name: /^New task$/i }); + const rerunTaskButton = page.getByRole("button", { name: /Rerun task/i }); + const runNowButton = page.getByRole("button", { name: /Run now/i }); + const actionButtons = [ + setupTaskButton, + newTaskButton, + rerunTaskButton, + runNowButton, + ]; + + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle").catch(() => undefined); + + const timeoutAt = Date.now() + 20000; + + while (Date.now() < timeoutAt) { + if ( + await setupTaskButton + .first() + .isVisible() + .catch(() => false) + ) { + const clicked = await clickActionButton(setupTaskButton.first()); + if (!clicked) { + await page.waitForTimeout(250); + continue; + } + + const runDialog = await waitForRunDialog(page); + await fillVisibleTaskInputs(runDialog); + await clickStartOrSimulateTask(page, runDialog); + return; + } + + if ( + await newTaskButton + .first() + .isVisible() + .catch(() => false) + ) { + const clicked = await clickActionButton(newTaskButton.first()); + if (!clicked) { + await page.waitForTimeout(250); + continue; + } + + const runDialog = await waitForRunDialog(page); + await fillVisibleTaskInputs(runDialog); + await clickStartOrSimulateTask(page, runDialog); + return; + } + + if ( + await rerunTaskButton + .first() + .isVisible() + .catch(() => false) + ) { + const clicked = await clickActionButton(rerunTaskButton.first()); + if (!clicked) { + await page.waitForTimeout(250); + continue; + } + + return; + } + + if ( + await runNowButton + .first() + .isVisible() + .catch(() => false) + ) { + const clicked = await clickActionButton(runNowButton.first()); + if (!clicked) { + await page.waitForTimeout(250); + continue; + } + + return; + } + + await page.waitForTimeout(250); + } + + const visibleButtons = await page + .getByRole("button") + .evaluateAll((elements) => + elements + .filter((element) => { + const htmlElement = element as HTMLElement; + const rect = htmlElement.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }) + .map((element) => element.textContent?.trim()) + .filter(Boolean), + ); + + throw new Error( + `Could not find run/start task button. URL: ${page.url()}. Visible buttons: ${visibleButtons.join(", ") || "none"}. Expected one of: ${actionButtons + .map((button) => button.toString()) + .join(", ")}`, + ); +} + +async function clickActionButton(button: Locator): Promise { + try { + await expect(button).toBeVisible({ timeout: 2000 }); + await expect(button).toBeEnabled({ timeout: 2000 }); + await button.click({ timeout: 3000 }); + return true; + } catch { + return false; + } +} + +async function waitForRunDialog(page: Page): Promise { + const runDialog = page + .locator("[data-dialog-content]") + .filter({ + has: page.getByRole("button", { name: /^Start Task$/i }), + }) + .last(); + await expect(runDialog).toBeVisible({ timeout: 15000 }); + return runDialog; +} + +async function dismissRunSafetyPopup(page: Page): Promise { + const safetyPopup = page + .locator("[data-dialog-content]") + .filter({ + has: page.getByText("Safety Checks Enabled", { exact: true }), + }) + .last(); + + if (!(await safetyPopup.isVisible({ timeout: 2000 }).catch(() => false))) { + return; + } + + await safetyPopup.getByRole("button", { name: /^Got it$/i }).click(); + await expect(safetyPopup).toBeHidden({ timeout: 10000 }); +} + +async function clickStartOrSimulateTask( + page: Page, + runDialog: Locator, +): Promise { + const startBtn = runDialog.getByRole("button", { name: /^Start Task$/i }); + // Happy-path tests must exercise a real run — do NOT fall back to the + // "Simulate" button if Start fails, because a broken Start code path is + // exactly the regression these tests exist to catch. + await expect(startBtn).toBeVisible({ timeout: 10000 }); + await expect(startBtn).toBeEnabled({ timeout: 10000 }); + await startBtn.click(); + await dismissRunSafetyPopup(page); + + await expect + .poll( + () => { + const currentUrl = new URL(page.url()); + return ( + currentUrl.searchParams.get("activeTab") === "runs" && + currentUrl.searchParams.get("activeItem") !== null + ); + }, + { + timeout: 15000, + message: + "Start Task click did not navigate to a run detail (?activeTab=runs&activeItem=...)", + }, + ) + .toBe(true); +} + +async function fillVisibleTaskInputs(container: Page | Locator): Promise { + const seededEmail = getSeededTestUser("smokeMarketplace").email; + const inputs = container.locator( + 'input:visible:not([type="hidden"]):not([type="file"]):not([disabled]), textarea:visible:not([disabled])', + ); + const inputCount = await inputs.count(); + + for (let index = 0; index < inputCount; index += 1) { + const input = inputs.nth(index); + const currentValue = await input.inputValue().catch(() => ""); + if (currentValue.trim()) { + continue; + } + + const type = (await input.getAttribute("type"))?.toLowerCase() ?? "text"; + const inputMetadata = await input.evaluate((element) => { + const formField = element as HTMLInputElement | HTMLTextAreaElement; + const closestLabel = formField.closest("label")?.textContent ?? ""; + const forLabel = formField.id + ? (document.querySelector(`label[for="${CSS.escape(formField.id)}"]`) + ?.textContent ?? "") + : ""; + + return { + placeholder: formField.getAttribute("placeholder") ?? "", + ariaLabel: formField.getAttribute("aria-label") ?? "", + name: formField.getAttribute("name") ?? "", + labelText: `${closestLabel} ${forLabel}`.trim(), + }; + }); + const fieldDescriptor = [ + inputMetadata.placeholder, + inputMetadata.ariaLabel, + inputMetadata.name, + inputMetadata.labelText, + ] + .join(" ") + .toLowerCase(); + + if (type === "checkbox" || type === "radio") { + continue; + } + + const value = + type === "email" || fieldDescriptor.includes("email") + ? seededEmail + : type === "number" || + /\b(a|b)\b/.test(fieldDescriptor) || + fieldDescriptor.includes("number") + ? "1" + : "e2e-input"; + + await input.fill(value).catch(() => {}); + } +} + +export async function clickNewRunButton(page: Page): Promise { + await getNewRunButton(page).click(); +} + +export async function runAgent(page: Page): Promise { + await clickRunButton(page); +} + +export async function waitForAgentPageLoad( + page: Page, + agentName?: string, +): Promise { + await page.waitForURL(/.*\/library\/agents\/[^/]+/); + // Wait for the primary content area to be present so the page has settled + // into its final state (empty view vs sidebar view) + await page.waitForLoadState("domcontentloaded"); + + // Transient "Something went wrong — All connection attempts failed" error + // boundary appears when the library agent page loads before the backend + // has indexed a newly-cloned agent (race between marketplace "Add to + // Library" and backend availability). Click "Try Again" and re-settle. + const errorHeading = page.getByText("Something went wrong", { + exact: false, + }); + let errorResolved = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!(await errorHeading.isVisible({ timeout: 300 }).catch(() => false))) { + errorResolved = true; + break; + } + const tryAgain = page.getByRole("button", { name: "Try Again" }); + if (await tryAgain.isVisible({ timeout: 500 }).catch(() => false)) { + await tryAgain.click(); + } else { + await page.reload(); + } + await page.waitForLoadState("domcontentloaded"); + } + + if (!errorResolved) { + errorResolved = !(await errorHeading + .isVisible({ timeout: 300 }) + .catch(() => false)); + } + + if (!errorResolved) { + throw new Error( + "Library agent page remained on the connection-failure screen after 3 retries", + ); + } + + await waitForAgentDetailShell(page, agentName); +} + +async function waitForLibraryListToLeave(page: Page): Promise { + const librarySearch = page.getByTestId("library-textbox"); + await expect + .poll( + async () => { + const count = await librarySearch.count(); + if (count === 0) { + return "gone"; + } + + if ( + !(await librarySearch + .first() + .isVisible() + .catch(() => false)) + ) { + return "gone"; + } + + return "visible"; + }, + { timeout: 15000 }, + ) + .toBe("gone"); +} + +async function getVisibleAgentDetailSurface(page: Page): Promise { + const visibleSurfaces: Array<[string, Locator]> = [ + [ + "about-agent", + page.getByText("About this agent", { exact: true }).first(), + ], + [ + "setup-task", + page.getByRole("button", { name: /^Setup your task$/i }).first(), + ], + ["new-task", page.getByRole("button", { name: /^New task$/i }).first()], + ["scheduled-tab", page.getByRole("tab", { name: /^Scheduled$/i }).first()], + ]; + + for (const [surface, locator] of visibleSurfaces) { + if (await locator.isVisible().catch(() => false)) { + return surface; + } + } + + return "pending"; +} + +async function waitForAgentDetailShell( + page: Page, + agentName?: string, +): Promise { + await waitForLibraryListToLeave(page); + + await expect( + page.getByRole("link", { name: "My Library" }).first(), + ).toBeVisible({ + timeout: 15000, + }); + + if (agentName) { + await expect( + page + .locator(`a[href*="/library/agents/"]`) + .filter({ hasText: agentName }) + .first(), + ).toBeVisible({ timeout: 15000 }); + } + + await expect + .poll(() => getVisibleAgentDetailSurface(page), { timeout: 15000 }) + .not.toBe("pending"); +} + +export async function getAgentName(page: Page): Promise { + return (await getAgentTitle(page).textContent()) || ""; +} + +export async function isLoaded(page: Page): Promise { + return await page.locator("h1").isVisible(); +} + +const SUCCESS_RUN_STATUS = "completed"; +const FAILURE_RUN_STATUSES = new Set(["failed", "terminated", "incomplete"]); +const RUN_ERROR_RECOVERY_GRACE_PERIOD_MS = 1500; +const RUN_ERROR_RECOVERY_ATTEMPTS = 2; + +/** + * Assert that a completed run actually produced output. + * + * The Library run-detail Output panel renders "No output from this run." when + * the run object has no `outputs` field. There's a brief window after the run + * reaches "completed" status where the run object is loaded without outputs, + * then outputs arrive and the panel re-renders. We poll for up to `timeout` + * ms waiting for the "No output" placeholder to GO AWAY before concluding + * the run genuinely produced nothing. + * + * This catches the "agent runs but produces nothing" failure mode + * (disconnected edges, broken graph, runtime crash before any output node + * fired) — the exact regression that ACCEPTED_RUN_STATUSES previously hid. + */ +export async function assertRunProducedOutput( + page: Page, + timeout = 15000, +): Promise { + await openRunOutputTab(page); + + // A completed run must surface output on the CURRENT render without a + // page reload. Reloading to "rule out stale cache" would mask a real + // user-visible regression where the frontend only shows output after a + // manual refresh. + const noOutput = page.getByText("No output from this run.", { exact: true }); + await expect(noOutput, { + message: + 'run completed but produced no output ("No output from this run." still shown) — broken graph, missing output node, or stale React Query cache', + }).toBeHidden({ timeout }); +} + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function openRunOutputTab(page: Page): Promise { + const outputTab = page.getByRole("tab", { name: /^Output$/i }).first(); + if (await outputTab.isVisible().catch(() => false)) { + await outputTab.click(); + return; + } + + const outputButton = page.getByRole("button", { name: /^Output$/i }).first(); + if (await outputButton.isVisible().catch(() => false)) { + await outputButton.click(); + } +} + +export async function assertRunOutputValue( + page: Page, + outputName: string, + expectedValue: RegExp | string, + timeout = 15000, +): Promise { + await openRunOutputTab(page); + + const outputLabel = page.locator("p.capitalize:visible").filter({ + hasText: new RegExp(`^${escapeRegex(outputName)}$`, "i"), + }); + + await expect( + outputLabel, + `run output should include output key "${outputName}"`, + ).toBeVisible({ timeout }); + + const outputValue = outputLabel.locator("xpath=following-sibling::*[1]"); + if (expectedValue instanceof RegExp) { + await expect( + outputValue, + `run output value for "${outputName}" should match ${expectedValue.toString()}`, + ).toHaveText(expectedValue, { timeout }); + return; + } + + await expect( + outputValue, + `run output value for "${outputName}" should be "${expectedValue}"`, + ).toHaveText(expectedValue, { timeout }); +} + +export async function assertFirstRunOutputValue( + page: Page, + expectedValue: RegExp | string, + timeout = 15000, +): Promise { + await assertRunOutputContainsText(page, expectedValue, timeout); +} + +export async function assertRunOutputContainsText( + page: Page, + expectedValue: RegExp | string, + timeout = 15000, +): Promise { + await openRunOutputTab(page); + + const outputCard = page + .locator("div") + .filter({ + has: page.getByRole("button", { name: "Copy all text outputs" }), + }) + .first(); + await expect(outputCard, "run output card should be visible").toBeVisible({ + timeout, + }); + + if (expectedValue instanceof RegExp) { + await expect( + outputCard.getByText(expectedValue).first(), + `run output should contain text matching ${expectedValue.toString()}`, + ).toBeVisible({ timeout }); + return; + } + + await expect( + outputCard.getByText(expectedValue, { exact: true }).first(), + `run output should contain "${expectedValue}"`, + ).toBeVisible({ timeout }); +} + +export async function waitForRunToComplete( + page: Page, + timeout = 45000, +): Promise { + const start = Date.now(); + let lastStatus = "unknown"; + let runErrorDetectedAt: number | null = null; + let recoveryAttempts = 0; + while (Date.now() - start < timeout) { + lastStatus = await getRunStatus(page); + if (lastStatus === SUCCESS_RUN_STATUS) { + return; + } + if (lastStatus === "error") { + runErrorDetectedAt ??= Date.now(); + if ( + Date.now() - runErrorDetectedAt >= + RUN_ERROR_RECOVERY_GRACE_PERIOD_MS + ) { + if (recoveryAttempts >= RUN_ERROR_RECOVERY_ATTEMPTS) { + throw new Error(`Run reached terminal failure state "${lastStatus}"`); + } + recoveryAttempts += 1; + runErrorDetectedAt = null; + await page.reload(); + await waitForAgentPageLoad(page); + continue; + } + } else { + runErrorDetectedAt = null; + } + if (FAILURE_RUN_STATUSES.has(lastStatus)) { + throw new Error(`Run reached terminal failure state "${lastStatus}"`); + } + await page.waitForTimeout(250); + } + throw new Error( + `waitForRunToComplete timed out after ${timeout}ms — last status was "${lastStatus}" (expected "${SUCCESS_RUN_STATUS}")`, + ); +} + +export function getActiveItemId(page: Page): string | null { + return new URL(page.url()).searchParams.get("activeItem"); +} + +export async function dismissFeedbackDialog(page: Page): Promise { + const feedbackDialog = page.getByRole("dialog", { + name: "We'd love your feedback", + }); + // Dialog is genuinely optional — it only appears on some run completions. + // Give it a realistic window to animate in; 500ms races the dialog + // transition and causes later clicks to land on it instead of the button + // behind it. + if (!(await feedbackDialog.isVisible({ timeout: 3000 }).catch(() => false))) { + return; + } + + const cancelButton = feedbackDialog.getByRole("button", { name: "Cancel" }); + if (await cancelButton.isVisible()) { + await cancelButton.click(); + await expect(feedbackDialog).toBeHidden({ timeout: 15000 }); + return; + } + + await feedbackDialog.getByRole("button", { name: "Close" }).click(); + await expect(feedbackDialog).toBeHidden({ timeout: 15000 }); +} + +export async function importAgentFromFile( + page: Page, + filePath: string, + agentName: string, + description: string = "PR E2E library coverage", +): Promise<{ libraryPage: LibraryPage; importedAgent: Agent }> { + const libraryPage = new LibraryPage(page); + const importDialog = page.getByRole("dialog", { name: "Import" }); + + await page.goto("/library"); + await libraryPage.openUploadDialog(); + await libraryPage.fillUploadForm(agentName, description); + + const fileInput = importDialog.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + const uploadButton = importDialog.getByRole("button", { name: "Upload" }); + await expect(uploadButton).toBeEnabled({ + timeout: 10000, + }); + await uploadButton.click(); + const uploadingButton = importDialog.getByRole("button", { + name: /Uploading\.\.\./i, + }); + const sawUploadingState = await uploadingButton + .waitFor({ state: "visible", timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (sawUploadingState) { + await expect + .poll( + async () => { + if (/\/build/.test(page.url())) { + return "build"; + } + if (!(await uploadingButton.isVisible().catch(() => false))) { + return "gone"; + } + return (await uploadingButton.isDisabled().catch(() => false)) + ? "disabled" + : "enabled"; + }, + { + timeout: 5000, + message: + 'upload button should either stay disabled while "Uploading..." is visible or disappear because navigation already started', + }, + ) + .not.toBe("enabled"); + } + + // Upload → backend creates the graph → router pushes /build?flowID=... + // This pipeline includes file parsing plus a backend graph creation call. + // On a cold stack it can take longer than a normal UI transition, so poll + // for the real terminal states: builder navigation or an explicit error. + await expect + .poll( + async () => { + if (/\/build/.test(page.url())) { + return "build"; + } + + const uploadFailed = await page + .getByText("Error Uploading agent") + .isVisible() + .catch(() => false); + if (uploadFailed) { + return "failed"; + } + + return "pending"; + }, + { + timeout: 60000, + message: + "agent import should either navigate to /build or surface an explicit upload error toast", + }, + ) + .toBe("build"); + await expect(page).toHaveURL(/\/build/, { timeout: 15000 }); + + // Import should produce a real graph, not an empty canvas. Lazy-import + // BuildPage locally to avoid a circular dependency between the two + // page-object modules. + const { BuildPage } = await import("./build.page"); + const importedBuildPage = new BuildPage(page); + await importedBuildPage.waitForNodeOnCanvas(); + const importedNodeCount = await importedBuildPage.getNodeCount(); + expect( + importedNodeCount, + "imported agent must render at least one node on canvas", + ).toBeGreaterThan(0); + + await page.goto("/library"); + await libraryPage.searchAgents(agentName); + await libraryPage.waitForAgentsToLoad(); + + // Look up the specific imported card directly rather than calling + // getAgents() in a loop. getAgents() iterates every visible card and + // reads hrefs via `.getAttribute`, which deadlocks if the library list + // re-renders mid-iteration (previously caused this test to hang 120s on + // the 8th card). A filter-based lookup on the agent name is both faster + // and immune to list churn. + const { getId } = getSelectors(page); + const importedCard = getId("library-agent-card") + .filter({ hasText: agentName }) + .first(); + await expect( + importedCard, + `imported agent card "${agentName}" must appear in the library search results`, + ).toBeVisible({ timeout: 15000 }); + + const seeRunsLink = getId("library-agent-card-see-runs-link", importedCard); + const seeRunsUrl = (await seeRunsLink.getAttribute("href")) ?? ""; + const openInBuilderLink = getId( + "library-agent-card-open-in-builder-link", + importedCard, + ); + const openInBuilderUrl = + (await openInBuilderLink.count()) > 0 + ? ((await openInBuilderLink.getAttribute("href")) ?? "") + : ""; + + const idMatch = seeRunsUrl.match(/\/library\/agents\/([^/]+)/); + const importedAgent: Agent = { + id: idMatch ? idMatch[1] : "", + name: + ( + await getId("library-agent-card-name", importedCard).textContent() + )?.trim() ?? agentName, + description: "", + seeRunsUrl, + openInBuilderUrl, + }; + + expect( + importedAgent.name, + "imported agent name should contain the requested name", + ).toContain(agentName); + + return { libraryPage, importedAgent }; +} + +export async function openSavedAgentInLibrary( + page: Page, + agentName: string, +): Promise { + const libraryPage = new LibraryPage(page); + + await page.goto("/library"); + await libraryPage.waitForAgentsToLoad(); + await libraryPage.searchAgents(agentName); + await libraryPage.waitForAgentsToLoad(); + await navigateToAgentByName(page, agentName); + await waitForAgentPageLoad(page, agentName); +} + +async function waitForExportActionSurface( + page: Page, +): Promise<"direct" | "menu"> { + await expect + .poll( + async () => { + if ( + await getFirstVisibleLocator(page, "button", "Export agent to file") + ) { + return "direct"; + } + + if (await getFirstVisibleLocator(page, "button", "More actions")) { + return "menu"; + } + + return "pending"; + }, + { timeout: 30000 }, + ) + .not.toBe("pending"); + + if (await getFirstVisibleLocator(page, "button", "Export agent to file")) { + return "direct"; + } + + return "menu"; +} + +async function getFirstVisibleLocator( + page: Page, + role: "button" | "menuitem", + name: string, +): Promise { + const locator = page.getByRole(role, { name }); + const count = await locator.count(); + + for (let index = 0; index < count; index += 1) { + const candidate = locator.nth(index); + if (await candidate.isVisible().catch(() => false)) { + return candidate; + } + } + + return null; +} + +export async function clickExportAgent(page: Page): Promise { + const exportSurface = await waitForExportActionSurface(page); + + if (exportSurface === "direct") { + const directExportButton = await getFirstVisibleLocator( + page, + "button", + "Export agent to file", + ); + if (!directExportButton) { + throw new Error( + "Export button was not visible after export surface resolved", + ); + } + + await directExportButton.click({ timeout: 15000 }); + return; + } + + const moreActionsButtons = page.getByRole("button", { name: "More actions" }); + const moreActionsCount = await moreActionsButtons.count(); + + for (let index = 0; index < moreActionsCount; index += 1) { + const moreActionsButton = moreActionsButtons.nth(index); + + if (!(await moreActionsButton.isVisible().catch(() => false))) { + continue; + } + + await moreActionsButton.click({ timeout: 15000 }); + + const exportMenuItem = await getFirstVisibleLocator( + page, + "menuitem", + "Export agent to file", + ); + if (exportMenuItem) { + await exportMenuItem.click({ timeout: 15000 }); + return; + } + + await page.keyboard.press("Escape").catch(() => {}); + } + + throw new Error( + "Export action was not available from any visible More actions menu", + ); +} + +// The run status is rendered by RunStatusBadge as lowercase text inside a +// `.capitalize` element (uppercased via CSS). Scoping to that class prevents +// false positives from free-text occurrences of words like "completed" +// elsewhere on the page (filter chips, tooltips, etc.). +const RUN_STATUS_WORDS = [ + "completed", + "failed", + "terminated", + "incomplete", + "queued", + "review", + "running", +] as const; + +export async function getRunStatus(page: Page): Promise { + // 1. Detect React error boundary first — fast loud failure if the page + // crashed mid-run, instead of polling until timeout. + const errorBoundary = page.getByText( + /Something went wrong|We had the following error|Application error/i, + ); + if ( + await errorBoundary + .first() + .isVisible({ timeout: 200 }) + .catch(() => false) + ) { + return "error"; + } + + // 2. Read the status from the scoped RunStatusBadge element. This is the + // only source of truth — no free-text matching across the whole page, + // no spinner heuristics that confuse a skeleton loader with a live run. + const badges = page.locator(".capitalize"); + const badgeCount = await badges.count().catch(() => 0); + for (let i = 0; i < badgeCount; i += 1) { + const badge = badges.nth(i); + if (!(await badge.isVisible().catch(() => false))) continue; + const text = ((await badge.textContent()) ?? "").trim().toLowerCase(); + if ((RUN_STATUS_WORDS as readonly string[]).includes(text)) { + return text; + } + } + + return "unknown"; +} diff --git a/autogpt_platform/frontend/src/playwright/pages/login.page.ts b/autogpt_platform/frontend/src/playwright/pages/login.page.ts new file mode 100644 index 0000000000..e5aab2d678 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/login.page.ts @@ -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 { + 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); + } + } + } +} diff --git a/autogpt_platform/frontend/src/playwright/pages/marketplace.page.ts b/autogpt_platform/frontend/src/playwright/pages/marketplace.page.ts new file mode 100644 index 0000000000..b0d334449f --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/marketplace.page.ts @@ -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 { + await this.searchAndOpenAgent(DETERMINISTIC_MARKETPLACE_AGENT_SEARCH); + await dismissFeedbackDialog(this.page); + } + + private async searchAndOpenAgent(agentName: string): Promise { + 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`); + } +} diff --git a/autogpt_platform/frontend/src/tests/pages/navbar.page.ts b/autogpt_platform/frontend/src/playwright/pages/navbar.page.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/pages/navbar.page.ts rename to autogpt_platform/frontend/src/playwright/pages/navbar.page.ts diff --git a/autogpt_platform/frontend/src/tests/pages/profile-form.page.ts b/autogpt_platform/frontend/src/playwright/pages/profile-form.page.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/pages/profile-form.page.ts rename to autogpt_platform/frontend/src/playwright/pages/profile-form.page.ts diff --git a/autogpt_platform/frontend/src/tests/pages/profile.page.ts b/autogpt_platform/frontend/src/playwright/pages/profile.page.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/pages/profile.page.ts rename to autogpt_platform/frontend/src/playwright/pages/profile.page.ts diff --git a/autogpt_platform/frontend/src/playwright/pages/settings.page.ts b/autogpt_platform/frontend/src/playwright/pages/settings.page.ts new file mode 100644 index 0000000000..7d32ccc23a --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/pages/settings.page.ts @@ -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 { + 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 { + await this.page.getByRole("button", { name: "Save preferences" }).click(); + await expect( + this.page.getByText("Successfully updated notification preferences"), + ).toBeVisible({ timeout: 15000 }); + } +} diff --git a/autogpt_platform/frontend/src/playwright/publish-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/publish-happy-path.spec.ts new file mode 100644 index 0000000000..00fcbaf1d4 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/publish-happy-path.spec.ts @@ -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 }); +}); diff --git a/autogpt_platform/frontend/src/playwright/settings-happy-path.spec.ts b/autogpt_platform/frontend/src/playwright/settings-happy-path.spec.ts new file mode 100644 index 0000000000..29dcd5187d --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/settings-happy-path.spec.ts @@ -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); +}); diff --git a/autogpt_platform/frontend/src/tests/utils/assertion.ts b/autogpt_platform/frontend/src/playwright/utils/assertion.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/utils/assertion.ts rename to autogpt_platform/frontend/src/playwright/utils/assertion.ts diff --git a/autogpt_platform/frontend/src/playwright/utils/auth.ts b/autogpt_platform/frontend/src/playwright/utils/auth.ts new file mode 100644 index 0000000000..2e737aa780 --- /dev/null +++ b/autogpt_platform/frontend/src/playwright/utils/auth.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const invalidKeys = await getInvalidSeededAuthStateKeys(baseURL); + + await Promise.all( + invalidKeys.map((accountKey) => + createAuthStateForUser(baseURL, accountKey), + ), + ); +} diff --git a/autogpt_platform/frontend/src/tests/utils/get-browser.ts b/autogpt_platform/frontend/src/playwright/utils/get-browser.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/utils/get-browser.ts rename to autogpt_platform/frontend/src/playwright/utils/get-browser.ts diff --git a/autogpt_platform/frontend/src/tests/utils/onboarding.ts b/autogpt_platform/frontend/src/playwright/utils/onboarding.ts similarity index 70% rename from autogpt_platform/frontend/src/tests/utils/onboarding.ts rename to autogpt_platform/frontend/src/playwright/utils/onboarding.ts index 375babc743..b5fa79abda 100644 --- a/autogpt_platform/frontend/src/tests/utils/onboarding.ts +++ b/autogpt_platform/frontend/src/playwright/utils/onboarding.ts @@ -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 }; } diff --git a/autogpt_platform/frontend/src/tests/utils/selectors.ts b/autogpt_platform/frontend/src/playwright/utils/selectors.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/utils/selectors.ts rename to autogpt_platform/frontend/src/playwright/utils/selectors.ts diff --git a/autogpt_platform/frontend/src/tests/utils/signin.ts b/autogpt_platform/frontend/src/playwright/utils/signin.ts similarity index 100% rename from autogpt_platform/frontend/src/tests/utils/signin.ts rename to autogpt_platform/frontend/src/playwright/utils/signin.ts diff --git a/autogpt_platform/frontend/src/tests/utils/signup.ts b/autogpt_platform/frontend/src/playwright/utils/signup.ts similarity index 98% rename from autogpt_platform/frontend/src/tests/utils/signup.ts rename to autogpt_platform/frontend/src/playwright/utils/signup.ts index 6b7802db9d..c83c760102 100644 --- a/autogpt_platform/frontend/src/tests/utils/signup.ts +++ b/autogpt_platform/frontend/src/playwright/utils/signup.ts @@ -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 { 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..."); diff --git a/autogpt_platform/frontend/src/tests/AGENTS.md b/autogpt_platform/frontend/src/tests/AGENTS.md index 1969708e8c..87222559af 100644 --- a/autogpt_platform/frontend/src/tests/AGENTS.md +++ b/autogpt_platform/frontend/src/tests/AGENTS.md @@ -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 diff --git a/autogpt_platform/frontend/src/tests/agent-activity.spec.ts b/autogpt_platform/frontend/src/tests/agent-activity.spec.ts deleted file mode 100644 index 4ae4a11d0c..0000000000 --- a/autogpt_platform/frontend/src/tests/agent-activity.spec.ts +++ /dev/null @@ -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 }); -}); diff --git a/autogpt_platform/frontend/src/tests/agent-dashboard.spec.ts b/autogpt_platform/frontend/src/tests/agent-dashboard.spec.ts deleted file mode 100644 index ec7ac3bfa0..0000000000 --- a/autogpt_platform/frontend/src/tests/agent-dashboard.spec.ts +++ /dev/null @@ -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/); -}); diff --git a/autogpt_platform/frontend/src/tests/api-keys.spec.ts b/autogpt_platform/frontend/src/tests/api-keys.spec.ts deleted file mode 100644 index 8c59ced981..0000000000 --- a/autogpt_platform/frontend/src/tests/api-keys.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/build.spec.ts b/autogpt_platform/frontend/src/tests/build.spec.ts deleted file mode 100644 index ad0b9524d0..0000000000 --- a/autogpt_platform/frontend/src/tests/build.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/credentials/index.ts b/autogpt_platform/frontend/src/tests/credentials/index.ts deleted file mode 100644 index bc4663a045..0000000000 --- a/autogpt_platform/frontend/src/tests/credentials/index.ts +++ /dev/null @@ -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; diff --git a/autogpt_platform/frontend/src/tests/global-setup.ts b/autogpt_platform/frontend/src/tests/global-setup.ts deleted file mode 100644 index 901eb117ef..0000000000 --- a/autogpt_platform/frontend/src/tests/global-setup.ts +++ /dev/null @@ -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; diff --git a/autogpt_platform/frontend/src/tests/integrations/vitest.setup.tsx b/autogpt_platform/frontend/src/tests/integrations/vitest.setup.tsx index bda6a2679d..c4931856bc 100644 --- a/autogpt_platform/frontend/src/tests/integrations/vitest.setup.tsx +++ b/autogpt_platform/frontend/src/tests/integrations/vitest.setup.tsx @@ -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()); diff --git a/autogpt_platform/frontend/src/tests/library.spec.ts b/autogpt_platform/frontend/src/tests/library.spec.ts deleted file mode 100644 index 98ba698398..0000000000 --- a/autogpt_platform/frontend/src/tests/library.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/marketplace-agent.spec.ts b/autogpt_platform/frontend/src/tests/marketplace-agent.spec.ts deleted file mode 100644 index fb38b90d63..0000000000 --- a/autogpt_platform/frontend/src/tests/marketplace-agent.spec.ts +++ /dev/null @@ -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`), - ); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts b/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts deleted file mode 100644 index 6fbf4d39be..0000000000 --- a/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts +++ /dev/null @@ -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\/.+/); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/marketplace.spec.ts b/autogpt_platform/frontend/src/tests/marketplace.spec.ts deleted file mode 100644 index 83b0d81d92..0000000000 --- a/autogpt_platform/frontend/src/tests/marketplace.spec.ts +++ /dev/null @@ -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 ✅", - ); - }); -}); diff --git a/autogpt_platform/frontend/src/tests/onboarding.spec.ts b/autogpt_platform/frontend/src/tests/onboarding.spec.ts deleted file mode 100644 index 321469c268..0000000000 --- a/autogpt_platform/frontend/src/tests/onboarding.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/autogpt_platform/frontend/src/tests/pages/build.page.ts b/autogpt_platform/frontend/src/tests/pages/build.page.ts deleted file mode 100644 index ad44f94f94..0000000000 --- a/autogpt_platform/frontend/src/tests/pages/build.page.ts +++ /dev/null @@ -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 { - await this.page.goto("/build"); - await this.page.waitForLoadState("domcontentloaded"); - } - - async isLoaded(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - return await this.getNodeLocator().count(); - } - - async waitForNodeOnCanvas(expectedCount?: number): Promise { - 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 { - const node = this.getNodeLocator(index); - await node.click(); - } - - async selectAllNodes(): Promise { - 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 { - await this.page.keyboard.press("Backspace"); - } - - // --- Connections (Edges) --- - - async connectNodes( - sourceNodeIndex: number, - targetNodeIndex: number, - ): Promise { - // 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 { - return await this.page.locator(".react-flow__edge").count(); - } - - // --- Save --- - - async saveAgent( - name: string = "Test Agent", - description: string = "", - ): Promise { - 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 { - await expect(this.page).toHaveURL(/flowID=/, { timeout: 15000 }); - } - - async waitForSaveButton(): Promise { - await this.page.waitForSelector( - '[data-testid="save-control-save-button"]:not([disabled])', - { timeout: 10000 }, - ); - } - - // --- Run --- - - async isRunButtonEnabled(): Promise { - const runButton = this.page.locator('[data-id="run-graph-button"]'); - return await runButton.isEnabled(); - } - - async clickRunButton(): Promise { - const runButton = this.page.locator('[data-id="run-graph-button"]'); - await runButton.click(); - } - - // --- Undo / Redo --- - - async isUndoEnabled(): Promise { - const btn = this.page.locator('[data-id="undo-button"]'); - return !(await btn.isDisabled()); - } - - async isRedoEnabled(): Promise { - const btn = this.page.locator('[data-id="redo-button"]'); - return !(await btn.isDisabled()); - } - - async clickUndo(): Promise { - await this.page.locator('[data-id="undo-button"]').click(); - } - - async clickRedo(): Promise { - await this.page.locator('[data-id="redo-button"]').click(); - } - - // --- Copy / Paste --- - - async copyViaKeyboard(): Promise { - const isMac = process.platform === "darwin"; - await this.page.keyboard.press(isMac ? "Meta+c" : "Control+c"); - } - - async pasteViaKeyboard(): Promise { - 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 { - const node = this.getNodeLocator(nodeIndex); - const input = node.getByPlaceholder(placeholder); - await input.fill(value); - } - - async clickCanvas(): Promise { - 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 { - await this.closeTutorial(); - await this.addBlockByClick("Add to Dictionary"); - await this.waitForNodeOnCanvas(1); - await this.saveAgent("Test Agent", "Test Description"); - await this.waitForSaveComplete(); - } -} diff --git a/autogpt_platform/frontend/src/tests/pages/library.page.ts b/autogpt_platform/frontend/src/tests/pages/library.page.ts deleted file mode 100644 index 716e6c3188..0000000000 --- a/autogpt_platform/frontend/src/tests/pages/library.page.ts +++ /dev/null @@ -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 { - 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 { - await this.page.goto("/library"); - await this.isLoaded(); - } - - async getAgentCount(): Promise { - 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 { - const { getId } = getSelectors(this.page); - const agentCards = await getId("library-agent-card").all(); - return agentCards.length; - } - - async searchAgents(searchTerm: string): Promise { - 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 { - 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 { - const { getRole } = getSelectors(page); - await getRole("combobox").click(); - - await getRole("option", sortOption).click(); - - await this.page.waitForTimeout(500); - } - - async getCurrentSortOption(): Promise { - 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 { - 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 { - await this.page.getByRole("button", { name: "Close" }).click(); - - await this.page.getByRole("dialog", { name: "Import" }).waitFor({ - state: "hidden", - timeout: 5_000, - }); - } - - async isUploadDialogVisible(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const { getText } = getSelectors(this.page); - const noAgentsText = getText("0 agents"); - return noAgentsText !== null; - } - - async scrollToBottom(): Promise { - console.log(`scrolling to bottom to trigger pagination`); - await this.page.keyboard.press("End"); - await this.page.waitForTimeout(1000); - } - - async scrollDown(): Promise { - console.log(`scrolling down to trigger pagination`); - await this.page.keyboard.press("PageDown"); - await this.page.waitForTimeout(1000); - } - - async scrollToLoadMore(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - await getLibraryTab(page).click(); - await page.waitForURL(/.*\/library/); -} - -export async function clickFirstAgent(page: Page): Promise { - const firstAgent = getAgentCards(page).first(); - await firstAgent.click(); -} - -export async function navigateToAgentByName( - page: Page, - agentName: string, -): Promise { - 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 { - 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 { - await getNewRunButton(page).click(); -} - -export async function runAgent(page: Page): Promise { - await clickRunButton(page); -} - -export async function waitForAgentPageLoad(page: Page): Promise { - 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 { - return (await getAgentTitle(page).textContent()) || ""; -} - -export async function isLoaded(page: Page): Promise { - return await page.locator("h1").isVisible(); -} - -export async function waitForRunToComplete( - page: Page, - timeout = 30000, -): Promise { - await page.waitForSelector(".bg-green-500, .bg-red-500, .bg-purple-500", { - timeout, - }); -} - -export async function getRunStatus(page: Page): Promise { - 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"; -} diff --git a/autogpt_platform/frontend/src/tests/pages/login.page.ts b/autogpt_platform/frontend/src/tests/pages/login.page.ts deleted file mode 100644 index 8472de06ed..0000000000 --- a/autogpt_platform/frontend/src/tests/pages/login.page.ts +++ /dev/null @@ -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"); - } - } -} diff --git a/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts b/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts deleted file mode 100644 index 51c2935abf..0000000000 --- a/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts +++ /dev/null @@ -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(); - } -} diff --git a/autogpt_platform/frontend/src/tests/profile-form.spec.ts b/autogpt_platform/frontend/src/tests/profile-form.spec.ts deleted file mode 100644 index 3ca593809c..0000000000 --- a/autogpt_platform/frontend/src/tests/profile-form.spec.ts +++ /dev/null @@ -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]); - } - }); -}); diff --git a/autogpt_platform/frontend/src/tests/profile.spec.ts b/autogpt_platform/frontend/src/tests/profile.spec.ts deleted file mode 100644 index 60f28e7372..0000000000 --- a/autogpt_platform/frontend/src/tests/profile.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/autogpt_platform/frontend/src/tests/publish-agent.spec.ts b/autogpt_platform/frontend/src/tests/publish-agent.spec.ts deleted file mode 100644 index e2dafef873..0000000000 --- a/autogpt_platform/frontend/src/tests/publish-agent.spec.ts +++ /dev/null @@ -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); -}); diff --git a/autogpt_platform/frontend/src/tests/settings.spec.ts b/autogpt_platform/frontend/src/tests/settings.spec.ts deleted file mode 100644 index 25ca0c337a..0000000000 --- a/autogpt_platform/frontend/src/tests/settings.spec.ts +++ /dev/null @@ -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); - } - } -}); diff --git a/autogpt_platform/frontend/src/tests/signin.spec.ts b/autogpt_platform/frontend/src/tests/signin.spec.ts deleted file mode 100644 index f7249ca059..0000000000 --- a/autogpt_platform/frontend/src/tests/signin.spec.ts +++ /dev/null @@ -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"); -}); diff --git a/autogpt_platform/frontend/src/tests/signup.spec.ts b/autogpt_platform/frontend/src/tests/signup.spec.ts deleted file mode 100644 index bcf5ea3725..0000000000 --- a/autogpt_platform/frontend/src/tests/signup.spec.ts +++ /dev/null @@ -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); - } -}); diff --git a/autogpt_platform/frontend/src/tests/title.spec.ts b/autogpt_platform/frontend/src/tests/title.spec.ts deleted file mode 100644 index 87cac8fe53..0000000000 --- a/autogpt_platform/frontend/src/tests/title.spec.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { test, expect } from "./coverage-fixture"; - -test("has title", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveTitle(/AutoGPT Platform/); -}); diff --git a/autogpt_platform/frontend/src/tests/util.spec.ts b/autogpt_platform/frontend/src/tests/util.spec.ts deleted file mode 100644 index 7e766457ac..0000000000 --- a/autogpt_platform/frontend/src/tests/util.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/autogpt_platform/frontend/src/tests/utils/auth.ts b/autogpt_platform/frontend/src/tests/utils/auth.ts deleted file mode 100644 index 8e5c0a90f7..0000000000 --- a/autogpt_platform/frontend/src/tests/utils/auth.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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]; -} diff --git a/autogpt_platform/frontend/src/types/auth.test.ts b/autogpt_platform/frontend/src/types/auth.test.ts new file mode 100644 index 0000000000..ef5c0b38e1 --- /dev/null +++ b/autogpt_platform/frontend/src/types/auth.test.ts @@ -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); + }); +}); diff --git a/autogpt_platform/frontend/vitest.config.mts b/autogpt_platform/frontend/vitest.config.mts index f91fc7442e..4e8c035673 100644 --- a/autogpt_platform/frontend/vitest.config.mts +++ b/autogpt_platform/frontend/vitest.config.mts @@ -16,6 +16,7 @@ export default defineConfig({ exclude: [ "src/**/*.test.{ts,tsx}", "src/**/*.stories.{ts,tsx}", + "src/playwright/**", "src/tests/**", ], },