""" Tests for Stage 90 — customer dashboard structure. This is mostly a static asset (React + Vite SPA). The Python test suite verifies: 1. Every key file exists at the expected path. 2. package.json declares the expected runtime + dev deps. 3. vite.config.ts proxies /api/v1 to the backend (dev-loop sanity — without this, sign-in doesn't work locally). 4. tsconfig.json has strict mode on. 5. The API client (api.ts) references all the routes the dashboard pages depend on — drift between pages and the client surface would silently break a page. 6. Each page file exists and is non-trivial. 7. The dashboard README has the right anchors (dev / build / deploy / API URL). Component-level tests live in JS land (future work via Vitest); this file validates the contract that customer-facing code expects the OrgState API to honor. """ import json from pathlib import Path import pytest ROOT = Path(__file__).parent.parent DASH = ROOT / "dashboard" SRC = DASH / "src" PAGES = SRC / "pages" # ========================================================= # File layout # ========================================================= def test_dashboard_dir_exists(): assert DASH.is_dir() def test_root_config_files_present(): for name in ("package.json", "vite.config.ts", "tsconfig.json", "tsconfig.node.json", "tailwind.config.js", "postcss.config.js", "index.html", "README.md", ".gitignore"): assert (DASH / name).exists(), f"dashboard/{name} missing" def test_src_files_present(): for name in ("main.tsx", "App.tsx", "auth.ts", "api.ts", "index.css"): assert (SRC / name).exists(), f"dashboard/src/{name} missing" def test_all_pages_present(): for page in ("Login.tsx", "Overview.tsx", "Runs.tsx", "RunDetail.tsx", "Webhooks.tsx"): path = PAGES / page assert path.exists(), f"dashboard/src/pages/{page} missing" # non-trivial — at least 30 lines assert len(path.read_text().splitlines()) > 30, \ f"{page} too short — likely placeholder" # ========================================================= # package.json # ========================================================= def test_package_json_parses(): spec = json.loads((DASH / "package.json").read_text()) assert spec["name"] == "orgstate-dashboard" assert spec["type"] == "module" def test_package_json_has_expected_scripts(): spec = json.loads((DASH / "package.json").read_text()) for script in ("dev", "build", "preview", "typecheck"): assert script in spec["scripts"], \ f"package.json missing script: {script}" def test_package_json_has_react_deps(): spec = json.loads((DASH / "package.json").read_text()) for dep in ("react", "react-dom", "react-router-dom"): assert dep in spec["dependencies"], \ f"missing runtime dep: {dep}" def test_package_json_has_build_devdeps(): spec = json.loads((DASH / "package.json").read_text()) for dep in ("vite", "typescript", "@vitejs/plugin-react", "tailwindcss"): assert dep in spec["devDependencies"], \ f"missing dev dep: {dep}" # ========================================================= # vite.config.ts # ========================================================= def test_vite_config_has_dev_proxy(): """Without /api/v1 → :8080 proxy, `npm run dev` can't talk to the local backend — sign-in fails with CORS.""" text = (DASH / "vite.config.ts").read_text() assert "/api/v1" in text assert "8080" in text assert "proxy" in text def test_vite_config_uses_react_plugin(): text = (DASH / "vite.config.ts").read_text() assert "@vitejs/plugin-react" in text assert "react()" in text # ========================================================= # tsconfig — strict + JSX # ========================================================= def test_tsconfig_has_strict_mode(): spec = json.loads((DASH / "tsconfig.json").read_text()) assert spec["compilerOptions"]["strict"] is True assert spec["compilerOptions"]["jsx"] == "react-jsx" # ========================================================= # api.ts ↔ pages contract # ========================================================= # Every method the pages call must exist in api.ts. Drift catches # 'used a method that doesn't exist' BEFORE the page breaks at # runtime. def _api_text() -> str: return (SRC / "api.ts").read_text() def _all_pages_text() -> str: return "\n".join(p.read_text() for p in PAGES.glob("*.tsx")) def test_api_has_all_methods_pages_use(): api = _api_text() pages = _all_pages_text() # extract every `api.(` call from pages import re used = set(re.findall(r"api\.(\w+)\(", pages)) for method in used: assert f"{method}:" in api, \ f"page uses api.{method}() but api.ts doesn't export it" def test_api_methods_have_known_routes(): """Each API method's path should reference a route family the backend actually exposes.""" api = _api_text() # smoke check — sample expected substrings for path in ("/tenants/", "/runs/", "/webhooks/", "/health", "/usage"): assert path in api, \ f"api.ts missing path family {path}" # ========================================================= # README anchors # ========================================================= def test_readme_has_install_dev_build_deploy_sections(): text = (DASH / "README.md").read_text() for anchor in ("## Dev", "## Build", "## Deploying", "VITE_API_BASE_URL"): assert anchor in text, f"README missing anchor: {anchor}" def test_readme_links_to_onboarding(): """Customer reading the dashboard README should know where their API key comes from (Stage 85's `infra onboard`).""" text = (DASH / "README.md").read_text() assert "infra onboard" in text # ========================================================= # Auth + localStorage contract # ========================================================= def test_auth_module_uses_localstorage(): """Sanity — auth.ts must read/write localStorage, not sessionStorage / cookies / etc. Stage 90 contract says 'sign-in persists across page refreshes'.""" text = (SRC / "auth.ts").read_text() assert "localStorage" in text assert "loadAuth" in text assert "saveAuth" in text assert "clearAuth" in text # ========================================================= # Optional: npm build smoke (gated on Node availability) # ========================================================= import shutil import subprocess @pytest.mark.skipif( shutil.which("npm") is None, reason="npm not installed; skipping JS build smoke", ) def test_npm_install_and_build(): """Heavy gate — runs `npm install` and `npm run build` to confirm the dashboard actually builds. Gated on `npm` availability so CI workers without Node still pass. Skipped if node_modules absent AND we don't have time to install (>30s on cold cache). We do `npm install --silent` once, then `npm run build` — both must succeed.""" # install — usually 30-60s on cold cache, near-instant warm r = subprocess.run( ["npm", "install", "--silent", "--no-audit", "--no-fund", "--no-progress"], cwd=str(DASH), capture_output=True, timeout=300, ) assert r.returncode == 0, \ f"npm install failed:\n{r.stderr.decode()}" # build — generous timeout for cold tsc + vite (warm: ~1s) r = subprocess.run( ["npm", "run", "build"], cwd=str(DASH), capture_output=True, timeout=300, ) assert r.returncode == 0, ( f"npm run build failed:\nSTDOUT: {r.stdout.decode()}" f"\nSTDERR: {r.stderr.decode()}" ) # dist/ exists with an index.html dist = DASH / "dist" assert dist.is_dir() assert (dist / "index.html").exists()