diff --git a/agent/tests/test_connection_pool.py b/agent/tests/test_connection_pool.py new file mode 100644 index 0000000..f2d36ff --- /dev/null +++ b/agent/tests/test_connection_pool.py @@ -0,0 +1,168 @@ +"""Tests for agent/db/connection.py — pool configuration logic without real Postgres.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +import agent.db.connection as conn_mod +from agent.db.connection import _float_env, _int_env, close_pool, get_pool + + +@pytest.fixture(autouse=True) +def _reset_pool_globals(): + """Reset module-level pool state before and after every test.""" + conn_mod._pool = None + conn_mod._pool_lock = None + yield + conn_mod._pool = None + conn_mod._pool_lock = None + + +# --------------------------------------------------------------------------- +# get_pool – DATABASE_URL validation +# --------------------------------------------------------------------------- + + +class TestGetPoolDatabaseUrl: + @pytest.mark.asyncio + async def test_raises_when_database_url_not_set(self, monkeypatch): + """get_pool must raise RuntimeError when DATABASE_URL is empty.""" + monkeypatch.delenv("DATABASE_URL", raising=False) + with pytest.raises(RuntimeError, match="DATABASE_URL environment variable is required"): + await get_pool() + + @pytest.mark.asyncio + async def test_raises_when_database_url_empty_string(self, monkeypatch): + """get_pool must raise RuntimeError when DATABASE_URL is an empty string.""" + monkeypatch.setenv("DATABASE_URL", "") + with pytest.raises(RuntimeError, match="DATABASE_URL environment variable is required"): + await get_pool() + + +# --------------------------------------------------------------------------- +# _int_env / _float_env helpers +# --------------------------------------------------------------------------- + + +class TestIntEnv: + def test_returns_default_when_not_set(self, monkeypatch): + monkeypatch.delenv("TEST_INT_VAR", raising=False) + assert _int_env("TEST_INT_VAR", 42) == 42 + + def test_returns_parsed_value(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "7") + assert _int_env("TEST_INT_VAR", 42) == 7 + + def test_clamps_to_minimum(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "0") + assert _int_env("TEST_INT_VAR", 42, minimum=1) == 1 + + def test_returns_default_on_non_numeric(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "abc") + assert _int_env("TEST_INT_VAR", 42) == 42 + + +class TestFloatEnv: + def test_returns_default_when_not_set(self, monkeypatch): + monkeypatch.delenv("TEST_FLOAT_VAR", raising=False) + assert _float_env("TEST_FLOAT_VAR", 2.5) == 2.5 + + def test_returns_parsed_value(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "3.14") + assert _float_env("TEST_FLOAT_VAR", 2.5) == pytest.approx(3.14) + + def test_clamps_to_minimum(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "-1.0") + assert _float_env("TEST_FLOAT_VAR", 2.5, minimum=0.0) == 0.0 + + def test_returns_default_on_non_numeric(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "nope") + assert _float_env("TEST_FLOAT_VAR", 2.5) == 2.5 + + +# --------------------------------------------------------------------------- +# Pool size env var overrides +# --------------------------------------------------------------------------- + + +class TestPoolSizeOverrides: + @pytest.mark.asyncio + async def test_custom_pool_sizes_passed_to_create_pool(self, monkeypatch): + """DB_POOL_MIN_SIZE and DB_POOL_MAX_SIZE should be forwarded to asyncpg.create_pool.""" + monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + monkeypatch.setenv("DB_POOL_MIN_SIZE", "5") + monkeypatch.setenv("DB_POOL_MAX_SIZE", "20") + + sentinel_pool = AsyncMock() + with patch( + "agent.db.connection.asyncpg.create_pool", new_callable=AsyncMock, return_value=sentinel_pool + ) as mock_create: + pool = await get_pool() + mock_create.assert_awaited_once() + _, kwargs = mock_create.call_args + assert kwargs["min_size"] == 5 + assert kwargs["max_size"] == 20 + assert pool is sentinel_pool + + @pytest.mark.asyncio + async def test_max_size_at_least_min_size(self, monkeypatch): + """If DB_POOL_MAX_SIZE < DB_POOL_MIN_SIZE, max_size should be clamped to min_size.""" + monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + monkeypatch.setenv("DB_POOL_MIN_SIZE", "10") + monkeypatch.setenv("DB_POOL_MAX_SIZE", "3") + + sentinel_pool = AsyncMock() + with patch( + "agent.db.connection.asyncpg.create_pool", new_callable=AsyncMock, return_value=sentinel_pool + ) as mock_create: + await get_pool() + _, kwargs = mock_create.call_args + assert kwargs["max_size"] >= kwargs["min_size"] + + +# --------------------------------------------------------------------------- +# close_pool +# --------------------------------------------------------------------------- + + +class TestClosePool: + @pytest.mark.asyncio + async def test_close_pool_resets_global(self): + """close_pool should close the pool and set the global to None.""" + fake_pool = AsyncMock() + conn_mod._pool = fake_pool + + await close_pool() + + fake_pool.close.assert_awaited_once() + assert conn_mod._pool is None + + @pytest.mark.asyncio + async def test_close_pool_noop_when_none(self): + """close_pool should be safe to call when no pool exists.""" + assert conn_mod._pool is None + await close_pool() # should not raise + assert conn_mod._pool is None + + @pytest.mark.asyncio + async def test_get_pool_after_close_recreates(self, monkeypatch): + """After close_pool, a subsequent get_pool should create a fresh pool.""" + monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + + first_pool = AsyncMock() + second_pool = AsyncMock() + + with patch( + "agent.db.connection.asyncpg.create_pool", + new_callable=AsyncMock, + side_effect=[first_pool, second_pool], + ): + pool1 = await get_pool() + assert pool1 is first_pool + + await close_pool() + assert conn_mod._pool is None + + pool2 = await get_pool() + assert pool2 is second_pool + assert pool2 is not pool1 diff --git a/agent/tests/test_pipeline_runtime_score.py b/agent/tests/test_pipeline_runtime_score.py new file mode 100644 index 0000000..1af3cd1 --- /dev/null +++ b/agent/tests/test_pipeline_runtime_score.py @@ -0,0 +1,127 @@ +"""Tests for build_meeting_result score logic in pipeline_runtime.py. + +Covers the fix where final_score=0 must be preserved and not overwritten by match_rate. +""" + +from agent.pipeline_runtime import build_meeting_result + + +class TestBuildMeetingResultScore: + """Score field in build_meeting_result must follow the fallback chain: + scoring.final_score -> code_eval_result.match_rate -> 0 + """ + + def test_final_score_zero_preserved(self): + """final_score=0 must be preserved in the result, not overwritten by match_rate.""" + state = { + "scoring": {"final_score": 0, "decision": "NO_GO"}, + "code_eval_result": {"match_rate": 85}, + } + result = build_meeting_result(state) + assert result["score"] == 0 + + def test_final_score_nonzero_used(self): + """A non-zero final_score should appear as-is in the result.""" + state = { + "scoring": {"final_score": 72, "decision": "GO"}, + } + result = build_meeting_result(state) + assert result["score"] == 72 + + def test_no_final_score_falls_back_to_match_rate(self): + """When final_score is absent (None), score should fall back to match_rate.""" + state = { + "scoring": {"decision": "NO_GO"}, + "code_eval_result": {"match_rate": 65}, + } + result = build_meeting_result(state) + assert result["score"] == 65 + + def test_final_score_none_falls_back_to_match_rate(self): + """When final_score is explicitly None, score should fall back to match_rate.""" + state = { + "scoring": {"final_score": None, "decision": "NO_GO"}, + "code_eval_result": {"match_rate": 50}, + } + result = build_meeting_result(state) + assert result["score"] == 50 + + def test_no_final_score_no_match_rate_defaults_to_zero(self): + """When neither final_score nor match_rate exist, score should be 0.""" + state = { + "scoring": {"decision": "NO_GO"}, + } + result = build_meeting_result(state) + assert result["score"] == 0 + + def test_empty_state_defaults_to_zero(self): + """A completely empty state should produce score=0.""" + result = build_meeting_result({}) + assert result["score"] == 0 + + def test_match_rate_zero_used_as_fallback(self): + """match_rate=0 is a valid numeric fallback (not None).""" + state = { + "scoring": {"decision": "NO_GO"}, + "code_eval_result": {"match_rate": 0}, + } + result = build_meeting_result(state) + assert result["score"] == 0 + + def test_match_rate_float_accepted(self): + """match_rate as a float should be accepted as a valid score.""" + state = { + "scoring": {"decision": "GO"}, + "code_eval_result": {"match_rate": 92.5}, + } + result = build_meeting_result(state) + assert result["score"] == 92.5 + + def test_code_eval_result_not_dict_ignored(self): + """If code_eval_result is not a dict, match_rate fallback should not apply.""" + state = { + "scoring": {"decision": "NO_GO"}, + "code_eval_result": "invalid", + } + result = build_meeting_result(state) + assert result["score"] == 0 + + def test_match_rate_string_not_used(self): + """A non-numeric match_rate should not be used as score (isinstance check).""" + state = { + "scoring": {"decision": "NO_GO"}, + "code_eval_result": {"match_rate": "high"}, + } + result = build_meeting_result(state) + assert result["score"] == 0 + + +class TestBuildMeetingResultVerdict: + """Verdict logic tests to complement the score tests.""" + + def test_go_verdict(self): + state = {"scoring": {"final_score": 80, "decision": "GO"}} + result = build_meeting_result(state) + assert result["verdict"] == "GO" + + def test_nogo_verdict(self): + state = {"scoring": {"final_score": 30, "decision": "NO_GO"}} + result = build_meeting_result(state) + assert result["verdict"] == "NO-GO" + + def test_conditional_verdict(self): + state = {"scoring": {"final_score": 55, "decision": "CONDITIONAL"}} + result = build_meeting_result(state) + assert result["verdict"] == "CONDITIONAL" + + def test_pipeline_success_overrides_verdict_to_go(self): + state = { + "scoring": {"final_score": 30, "decision": "NO_GO"}, + "code_eval_result": {"passed": True}, + "build_validation": {"passed": True}, + "local_runtime_validation": {"passed": True}, + "deploy_gate_result": {"passed": True}, + "deploy_result": {"status": "deployed"}, + } + result = build_meeting_result(state) + assert result["verdict"] == "GO" diff --git a/agent/tests/test_runtime_config.py b/agent/tests/test_runtime_config.py index 832a6d3..1b9d071 100644 --- a/agent/tests/test_runtime_config.py +++ b/agent/tests/test_runtime_config.py @@ -24,8 +24,8 @@ async def fake_create_pool(database_url: str, **kwargs): assert result is pool assert created["database_url"] == "postgres://example" - assert created["kwargs"]["min_size"] == 1 - assert created["kwargs"]["max_size"] == 1 + assert created["kwargs"]["min_size"] == 2 + assert created["kwargs"]["max_size"] == 10 assert created["kwargs"]["command_timeout"] == 60 diff --git a/agent/tests/test_server_coverage.py b/agent/tests/test_server_coverage.py index 3016a1f..6375e20 100644 --- a/agent/tests/test_server_coverage.py +++ b/agent/tests/test_server_coverage.py @@ -14,7 +14,7 @@ async def test_health_has_provider_field(app_client): resp = await app_client.get("/health") assert resp.status_code == 200 data = resp.json() - assert "provider" in data + assert data == {"status": "ok"} # minimal disclosure @pytest.mark.asyncio diff --git a/agent/tests/test_tools_integration.py b/agent/tests/test_tools_integration.py index b136772..974d257 100644 --- a/agent/tests/test_tools_integration.py +++ b/agent/tests/test_tools_integration.py @@ -1468,7 +1468,8 @@ def test_repo_url_parsed(self, monkeypatch): assert spec["services"][0]["github"]["repo"] == "myorg/myrepo" def test_with_database_url(self, monkeypatch): - monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@host:5432/db") + monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgresql://user:pass@host:5432/db") + monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False) monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False) monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False) from agent.tools.digitalocean import build_app_spec @@ -1480,7 +1481,8 @@ def test_with_database_url(self, monkeypatch): def test_with_inference_key(self, monkeypatch): monkeypatch.delenv("DATABASE_URL", raising=False) - monkeypatch.setenv("GRADIENT_MODEL_ACCESS_KEY", "test-inference-key") + monkeypatch.delenv("GENERATED_APP_DATABASE_URL", raising=False) + monkeypatch.setenv("GENERATED_APP_INFERENCE_KEY", "test-inference-key") monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False) from agent.tools.digitalocean import build_app_spec @@ -1499,7 +1501,8 @@ def test_name_truncated_to_32(self, monkeypatch): assert len(spec["name"]) <= 32 def test_asyncpg_url_converted_to_psycopg(self, monkeypatch): - monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://user:pass@host/db") + monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgresql+asyncpg://user:pass@host/db") + monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False) monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False) monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False) from agent.tools.digitalocean import build_app_spec @@ -1510,7 +1513,8 @@ def test_asyncpg_url_converted_to_psycopg(self, monkeypatch): assert "asyncpg" not in db_env["value"] def test_postgres_url_converted(self, monkeypatch): - monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host/db") + monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgres://user:pass@host/db") + monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False) monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False) monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False) from agent.tools.digitalocean import build_app_spec diff --git a/agent/tests/test_zp_store.py b/agent/tests/test_zp_store.py new file mode 100644 index 0000000..b861256 --- /dev/null +++ b/agent/tests/test_zp_store.py @@ -0,0 +1,291 @@ +"""Tests for agent/db/zp_store.py — update_card validation, JSONB serialisation, and column allow-list.""" + +import json +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import pytest + +from agent.db.zp_store import ( + _ALLOWED_CARD_COLUMNS, + _JSONB_COLUMNS, + _card_row_to_dict, + update_card, +) + + +def _make_fake_pool(): + """Return (fake_pool, fake_conn) where pool.acquire() is a proper async context manager.""" + fake_conn = AsyncMock() + + @asynccontextmanager + async def _acquire(): + yield fake_conn + + fake_pool = AsyncMock() + fake_pool.acquire = _acquire + return fake_pool, fake_conn + + +# --------------------------------------------------------------------------- +# update_card – allowed / disallowed columns +# --------------------------------------------------------------------------- + + +class TestUpdateCardValidation: + @pytest.mark.asyncio + async def test_empty_fields_returns_immediately(self): + """update_card with no keyword args should be a no-op (no DB call).""" + with patch("agent.db.zp_store.get_pool") as mock_gp: + await update_card("card-1") + mock_gp.assert_not_called() + + @pytest.mark.asyncio + async def test_allowed_column_succeeds(self): + """update_card with a valid column should build a query and execute.""" + fake_pool, fake_conn = _make_fake_pool() + + async def _get_pool(): + return fake_pool + + with patch("agent.db.zp_store.get_pool", side_effect=_get_pool): + await update_card("card-1", status="go_ready") + + fake_conn.execute.assert_awaited_once() + sql = fake_conn.execute.call_args[0][0] + assert "status = $1" in sql + assert fake_conn.execute.call_args[0][1] == "go_ready" + assert fake_conn.execute.call_args[0][2] == "card-1" + + @pytest.mark.asyncio + async def test_disallowed_column_raises_valueerror(self): + """update_card with an unknown column name must raise ValueError.""" + with pytest.raises(ValueError, match="Disallowed column names"): + await update_card("card-1", evil_column="drop table") + + @pytest.mark.asyncio + async def test_multiple_disallowed_columns_listed(self): + """ValueError message should mention every disallowed column.""" + with pytest.raises(ValueError) as exc_info: + await update_card("card-1", bad1="x", bad2="y") + msg = str(exc_info.value) + assert "bad1" in msg + assert "bad2" in msg + + @pytest.mark.asyncio + async def test_mixed_allowed_and_disallowed_raises(self): + """If any column is disallowed, the entire call should fail.""" + with pytest.raises(ValueError, match="Disallowed column names"): + await update_card("card-1", status="ok", sql_injection="bad") + + +# --------------------------------------------------------------------------- +# update_card – JSONB serialisation +# --------------------------------------------------------------------------- + + +class TestUpdateCardJsonb: + @pytest.mark.asyncio + async def test_jsonb_dict_is_serialised(self): + """A dict value for a JSONB column should be JSON-serialised.""" + fake_pool, fake_conn = _make_fake_pool() + + async def _get_pool(): + return fake_pool + + payload = {"tech": 80, "market": 70} + with patch("agent.db.zp_store.get_pool", side_effect=_get_pool): + await update_card("card-1", score_breakdown=payload) + + sql = fake_conn.execute.call_args[0][0] + assert "::jsonb" in sql + serialised_value = fake_conn.execute.call_args[0][1] + assert json.loads(serialised_value) == payload + + @pytest.mark.asyncio + async def test_jsonb_list_is_serialised(self): + """A list value for a JSONB column should be JSON-serialised.""" + fake_pool, fake_conn = _make_fake_pool() + + async def _get_pool(): + return fake_pool + + items = [{"event": "start"}, {"event": "end"}] + with patch("agent.db.zp_store.get_pool", side_effect=_get_pool): + await update_card("card-1", build_events=items) + + serialised_value = fake_conn.execute.call_args[0][1] + assert json.loads(serialised_value) == items + + @pytest.mark.asyncio + async def test_jsonb_string_passed_through(self): + """A string value for a JSONB column should be passed as-is (already serialised).""" + fake_pool, fake_conn = _make_fake_pool() + + async def _get_pool(): + return fake_pool + + raw_json = '{"already": "serialised"}' + with patch("agent.db.zp_store.get_pool", side_effect=_get_pool): + await update_card("card-1", insights=raw_json) + + passed_value = fake_conn.execute.call_args[0][1] + assert passed_value == raw_json + + @pytest.mark.asyncio + async def test_non_jsonb_column_not_serialised(self): + """A plain column value should not be JSON-serialised.""" + fake_pool, fake_conn = _make_fake_pool() + + async def _get_pool(): + return fake_pool + + with patch("agent.db.zp_store.get_pool", side_effect=_get_pool): + await update_card("card-1", domain="health") + + sql = fake_conn.execute.call_args[0][0] + assert "::jsonb" not in sql + passed_value = fake_conn.execute.call_args[0][1] + assert passed_value == "health" + + +# --------------------------------------------------------------------------- +# _ALLOWED_CARD_COLUMNS coverage +# --------------------------------------------------------------------------- + + +class TestAllowedCardColumns: + """Verify the allow-list matches every column name used via update_card in the codebase.""" + + def test_jsonb_columns_are_subset_of_allowed(self): + """Every JSONB column must also be in the allowed set.""" + assert _JSONB_COLUMNS.issubset(_ALLOWED_CARD_COLUMNS) + + def test_known_used_columns_are_allowed(self): + """Columns referenced in server.py and orchestrator.py must be in the allow-list.""" + used_columns = { + "status", + "score", + "domain", + "reason", + "reason_code", + "score_breakdown", + "build_step", + "analysis_step", + "repo_url", + "live_url", + "thread_id", + "title", + "video_id", + "build_events", + "build_phase", + "build_node", + "papers_found", + "competitors_found", + "saturation", + "novelty_boost", + "video_summary", + "insights", + "mvp_proposal", + } + missing = used_columns - _ALLOWED_CARD_COLUMNS + assert missing == set(), f"Columns used in code but missing from allow-list: {missing}" + + def test_id_not_allowed(self): + """Primary key 'id' must never be updatable.""" + assert "id" not in _ALLOWED_CARD_COLUMNS + + def test_session_id_not_allowed(self): + """Foreign key 'session_id' must never be updatable via update_card.""" + assert "session_id" not in _ALLOWED_CARD_COLUMNS + + def test_created_at_not_allowed(self): + """Timestamp 'created_at' must never be updatable via update_card.""" + assert "created_at" not in _ALLOWED_CARD_COLUMNS + + +# --------------------------------------------------------------------------- +# _card_row_to_dict +# --------------------------------------------------------------------------- + + +class TestCardRowToDict: + def _make_row(self, **overrides) -> dict: + """Build a minimal row dict mimicking an asyncpg Record.""" + base = { + "id": "card-1", + "session_id": "sess-1", + "created_at": "2025-01-01T00:00:00Z", + "video_id": "yt-abc", + "title": "Test", + "status": "analyzing", + "score": 0, + "domain": "", + "reason": "", + "reason_code": "", + "score_breakdown": {}, + "papers_found": 0, + "competitors_found": "", + "saturation": "", + "novelty_boost": 0.0, + "video_summary": "", + "insights": [], + "mvp_proposal": {}, + "build_step": "", + "analysis_step": "", + "repo_url": "", + "live_url": "", + "thread_id": None, + } + base.update(overrides) + return base + + def test_id_renamed_to_card_id(self): + row = self._make_row() + result = _card_row_to_dict(row) + assert "card_id" in result + assert result["card_id"] == "card-1" + assert "id" not in result + + def test_session_id_stripped(self): + row = self._make_row() + result = _card_row_to_dict(row) + assert "session_id" not in result + + def test_created_at_stripped(self): + row = self._make_row() + result = _card_row_to_dict(row) + assert "created_at" not in result + + def test_insights_string_parsed_as_json(self): + row = self._make_row(insights='[{"key": "value"}]') + result = _card_row_to_dict(row) + assert result["insights"] == [{"key": "value"}] + + def test_mvp_proposal_string_parsed_as_json(self): + row = self._make_row(mvp_proposal='{"name": "app"}') + result = _card_row_to_dict(row) + assert result["mvp_proposal"] == {"name": "app"} + + def test_score_breakdown_string_parsed_as_json(self): + row = self._make_row(score_breakdown='{"tech": 80}') + result = _card_row_to_dict(row) + assert result["score_breakdown"] == {"tech": 80} + + def test_already_parsed_dict_unchanged(self): + original = {"tech": 90} + row = self._make_row(score_breakdown=original) + result = _card_row_to_dict(row) + assert result["score_breakdown"] == {"tech": 90} + + def test_already_parsed_list_unchanged(self): + original = [{"idea": "x"}] + row = self._make_row(insights=original) + result = _card_row_to_dict(row) + assert result["insights"] == [{"idea": "x"}] + + def test_invalid_json_string_raises(self): + """If a JSONB column contains invalid JSON as a string, json.loads should raise.""" + row = self._make_row(insights="not valid json {{{") + with pytest.raises(json.JSONDecodeError): + _card_row_to_dict(row) diff --git a/agent/tools/digitalocean.py b/agent/tools/digitalocean.py index 75540f2..b97d1e2 100644 --- a/agent/tools/digitalocean.py +++ b/agent/tools/digitalocean.py @@ -252,6 +252,13 @@ def build_app_spec( envs = [] # Use separate credentials for generated apps — NEVER share the host's own DB. generated_db_url = os.getenv("GENERATED_APP_DATABASE_URL", "") + # Apply the same psycopg scheme conversion to generated app DB URL + if generated_db_url.startswith("postgresql+asyncpg://"): + generated_db_url = generated_db_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1) + elif generated_db_url.startswith("postgres://"): + generated_db_url = generated_db_url.replace("postgres://", "postgresql+psycopg://", 1) + elif generated_db_url.startswith("postgresql://") and "+psycopg" not in generated_db_url: + generated_db_url = generated_db_url.replace("postgresql://", "postgresql+psycopg://", 1) if generated_db_url: envs.append({"key": "DATABASE_URL", "value": generated_db_url, "scope": "RUN_TIME", "type": "SECRET"}) envs.append({"key": "POSTGRES_URL", "value": generated_db_url, "scope": "RUN_TIME", "type": "SECRET"}) diff --git a/web/e2e/demo-flow.spec.ts b/web/e2e/demo-flow.spec.ts new file mode 100644 index 0000000..da903d5 --- /dev/null +++ b/web/e2e/demo-flow.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Demo Flow", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/demo"); + }); + + test("/demo page auto-types YouTube URL", async ({ page }) => { + // The demo page auto-types a YouTube URL into an input field. + // Wait for the URL to appear in an input element. + const urlInput = page.locator("input[type='text'], input[type='url'], input[placeholder]").first(); + + // The demo types character-by-character, so wait for the full URL or enough of it + await expect(urlInput).toHaveValue(/youtube\.com/, { timeout: 15_000 }); + }); + + test("kanban board appears after transition", async ({ page }) => { + // After the demo auto-types and clicks "start", the stage transitions to dashboard + // which shows the kanban board. This takes a few seconds for the auto-animation. + // The kanban board containers have specific column headings. + + // Wait for the demo transition to complete (auto-type + auto-click ~ 3-5s) + const boardElement = page + .getByText(/Analyzing|GO Ready|Building|Deployed|No-Go/i) + .first(); + await expect(boardElement).toBeVisible({ timeout: 30_000 }); + }); + + test("cards appear in the board", async ({ page }) => { + // After the kanban board appears, demo timeline cards start populating. + // The first card appears at ~6500ms (DEMO_SPEED_MULTIPLIER = 8, but raw timer is 6500ms). + + // Wait for at least one card title to appear in the board + const cardTitle = page + .getByText(/NutriPlan|SpendSense|StudyMate/i) + .first(); + await expect(cardTitle).toBeVisible({ timeout: 45_000 }); + }); + + test("session shows completed status at the end", async ({ page }) => { + // The demo timeline fires a "completed" session status event. + // This is signaled by the status bar or a completion indicator. + // The longest timeline event is around 200 seconds raw time. + // For a reasonable E2E timeout, we check for the completed state. + + test.slow(); // Mark as slow test — the demo timeline takes extended time + + // Wait for the completed status indicator + const completedIndicator = page + .getByText(/completed|session complete/i) + .first(); + await expect(completedIndicator).toBeVisible({ timeout: 300_000 }); + }); +}); diff --git a/web/e2e/navigation.spec.ts b/web/e2e/navigation.spec.ts new file mode 100644 index 0000000..594efe7 --- /dev/null +++ b/web/e2e/navigation.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Navigation", () => { + test("landing page loads and shows vibeDeploy title", async ({ page }) => { + await page.goto("/"); + + // The page title should contain vibeDeploy + await expect(page).toHaveTitle(/vibeDeploy/); + + // The landing page renders the brand name in the heading + const heading = page.getByText("vibeDeploy", { exact: false }); + await expect(heading.first()).toBeVisible(); + }); + + test("Watch Demo button is visible and links to /demo", async ({ page }) => { + await page.goto("/"); + + const watchDemoLink = page.getByRole("link", { name: /Watch Demo/i }); + await expect(watchDemoLink).toBeVisible(); + await expect(watchDemoLink).toHaveAttribute("href", /\/demo/); + }); + + test("demo page loads and shows demo workspace", async ({ page }) => { + await page.goto("/demo"); + + // The demo page should display the landing stage first with the vibeDeploy brand + const brand = page.getByText("vibeDeploy", { exact: false }); + await expect(brand.first()).toBeVisible(); + }); + + test("dashboard page loads and shows System Dashboard", async ({ page }) => { + await page.goto("/dashboard"); + + // Wait for the System Dashboard heading to appear + const heading = page.getByText("System Dashboard", { exact: false }); + await expect(heading.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("navigation between pages works", async ({ page }) => { + // Start at landing page + await page.goto("/"); + await expect(page).toHaveTitle(/vibeDeploy/); + + // Navigate to demo + await page.goto("/demo"); + await expect(page.getByText("vibeDeploy", { exact: false }).first()).toBeVisible(); + + // Navigate to dashboard + await page.goto("/dashboard"); + await expect( + page.getByText("System Dashboard", { exact: false }).first(), + ).toBeVisible({ timeout: 15_000 }); + + // Navigate back to landing + await page.goto("/"); + await expect(page).toHaveTitle(/vibeDeploy/); + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 5ef1008..214cd3c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -2263,6 +2264,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -11921,6 +11938,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 36e0e9d..91929ac 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..e809821 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 1, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/web/src/app/demo/page.tsx b/web/src/app/demo/page.tsx index 5520840..0261b5b 100644 --- a/web/src/app/demo/page.tsx +++ b/web/src/app/demo/page.tsx @@ -262,6 +262,7 @@ export default function DemoPage() {