From e39819f8d04af3655dfa9b6d19ba7cdd06a291eb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 02:28:04 -0400 Subject: [PATCH] Add Playwright e2e coverage and Node 20 pin --- .github/workflows/ci.yml | 68 ++ .gitignore | 3 + .nvmrc | 1 + docker-compose.dev.yml | 6 +- ...16-stack-priorities-and-playwright-plan.md | 682 ++++++++++++++++++ frontend/e2e/README.md | 86 +++ frontend/e2e/analytics.spec.ts | 44 ++ frontend/e2e/auth.setup.ts | 68 ++ frontend/e2e/auth.spec.ts | 13 + frontend/e2e/helpers/api.ts | 196 +++++ frontend/e2e/helpers/auth.ts | 17 + frontend/e2e/history.spec.ts | 45 ++ frontend/e2e/library-start.spec.ts | 46 ++ frontend/e2e/library.spec.ts | 28 + frontend/e2e/navigation.spec.ts | 36 + frontend/e2e/profile.spec.ts | 20 + frontend/e2e/public.spec.ts | 25 + frontend/e2e/resume.spec.ts | 38 + frontend/e2e/session-workflow.spec.ts | 89 +++ frontend/e2e/shared-session.spec.ts | 46 ++ frontend/e2e/shares.spec.ts | 46 ++ frontend/package-lock.json | 60 ++ frontend/package.json | 8 + frontend/playwright.config.ts | 67 ++ frontend/src/components/layout/AppLayout.tsx | 5 +- .../scripts/PowerShellHighlighter.tsx | 4 +- frontend/src/pages/LoginPage.tsx | 3 +- 27 files changed, 1743 insertions(+), 7 deletions(-) create mode 100644 .nvmrc create mode 100644 docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md create mode 100644 frontend/e2e/README.md create mode 100644 frontend/e2e/analytics.spec.ts create mode 100644 frontend/e2e/auth.setup.ts create mode 100644 frontend/e2e/auth.spec.ts create mode 100644 frontend/e2e/helpers/api.ts create mode 100644 frontend/e2e/helpers/auth.ts create mode 100644 frontend/e2e/history.spec.ts create mode 100644 frontend/e2e/library-start.spec.ts create mode 100644 frontend/e2e/library.spec.ts create mode 100644 frontend/e2e/navigation.spec.ts create mode 100644 frontend/e2e/profile.spec.ts create mode 100644 frontend/e2e/public.spec.ts create mode 100644 frontend/e2e/resume.spec.ts create mode 100644 frontend/e2e/session-workflow.spec.ts create mode 100644 frontend/e2e/shared-session.spec.ts create mode 100644 frontend/e2e/shares.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a15eafb..63630367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,3 +94,71 @@ jobs: - name: Build run: cd frontend && npm run build + + e2e: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: patherly_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/patherly_test + PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@127.0.0.1:5432/patherly_test + PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000 + PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173 + PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key + PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com + PLAYWRIGHT_TEST_PASSWORD: TestPass123! + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + backend/requirements.txt + backend/requirements-dev.txt + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install backend dependencies + run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt + + - name: Install frontend dependencies + run: cd frontend && npm ci + + - name: Install Playwright browser + run: cd frontend && npx playwright install --with-deps chromium + + - name: Run Playwright smoke tests + run: cd frontend && npm run test:e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + frontend/playwright-report + frontend/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 753e18dd..8fe2a9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,9 @@ __marimo__/ # Temp/generated files backend/test_results.txt frontend/stats.html +frontend/playwright-report/ +frontend/test-results/ +frontend/e2e/.auth/ # Railway CLI (local tooling) node_modules/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5bd68117 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.0 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dd3de5cd..f15d4410 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: resolutionflow ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - rf_postgres_data:/var/lib/postgresql/data healthcheck: @@ -39,7 +39,7 @@ services: - AI_PROVIDER=anthropic - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY} - - CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] + - CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173"] depends_on: db: condition: service_healthy @@ -61,4 +61,4 @@ services: volumes: rf_postgres_data: - \ No newline at end of file + diff --git a/docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md b/docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md new file mode 100644 index 00000000..d0c946f9 --- /dev/null +++ b/docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md @@ -0,0 +1,682 @@ +# Stack Priorities And Playwright Plan + +> **Date:** 2026-03-16 +> **Product:** ResolutionFlow +> **Purpose:** Turn the recent stack-gap review into a practical, sequenced execution plan + +--- + +## Summary + +ResolutionFlow already has a credible application stack: + +- React 19 + TypeScript + Vite on the frontend +- FastAPI + async SQLAlchemy + PostgreSQL on the backend +- Sentry on both frontend and backend +- CI with backend coverage plus frontend lint/test/build +- Strong backend integration test coverage +- Route-level lazy loading and bundle chunking already in place + +The next step is not a stack rewrite. + +The biggest gains now come from: + +1. Better product visibility +2. Better release confidence +3. Better enterprise trust signals +4. Better workflow gravity inside the app + +--- + +## Ranked Recommendations + +### 1. Fastest Wins + +These are the best short-term upgrades if the goal is to make the product feel more polished and more professional quickly. + +#### 1. Product analytics instrumentation +**Why:** Sentry tells us when the app breaks. It does not tell us what users value, where they stall, or what converts. + +**Recommended:** PostHog + +**Track first:** +- Account created +- First successful login +- First flow viewed +- First session started +- First session completed +- First export generated +- First AI feature used +- First PSA integration connected +- First shared session created + +**Why this is a fast win:** High leverage with low UI churn. + +#### 2. Better empty states and onboarding guidance +**Why:** Mature apps reduce ambiguity. Empty libraries, empty analytics, and empty integrations pages should guide the next action immediately. + +**Add first:** +- Empty flow library CTA +- Empty analytics explanation + “how data appears here” +- Empty integrations state with benefit-oriented copy +- New team “starter checklist” + +#### 3. Professional exports +**Why:** Exports are one of the fastest ways for a B2B product to feel premium. + +**Add first:** +- Client-ready PDF export +- Logo/header metadata block +- Cleaner ticket/session summary layout +- Optional evidence attachment section + +#### 4. Coverage gates in CI +**Why:** Cheap trust signal internally. Prevents quality drift as the codebase expands. + +**Add first:** +- Fail backend CI if total coverage drops below agreed threshold +- Publish frontend coverage report + +--- + +### 2. Best ROI + +These are the best medium-term investments if the goal is to improve product quality and roadmap clarity without taking on huge platform risk. + +#### 1. Playwright end-to-end coverage +**Why:** Backend coverage is strong, but frontend confidence is still thinner than the app’s complexity now deserves. + +**High-value flows to cover first:** +- Login +- Authenticated app shell loads +- Session history loads +- Account settings save flow +- Feedback submission +- Shared session page access + +**Why this is high ROI:** It catches real regressions users actually feel. + +#### 2. Security header hardening +**Why:** MSP buyers care about security posture. This is both a real protection layer and a professionalism layer. + +**Add first:** +- Content-Security-Policy +- Strict-Transport-Security +- X-Frame-Options or CSP `frame-ancestors` +- Referrer-Policy +- Permissions-Policy +- Trusted host validation where appropriate + +#### 3. Web Vitals and performance budgeting +**Why:** Route splitting is already implemented, so the next step is protecting performance over time. + +**Track first:** +- LCP +- INP +- CLS +- Initial JS size +- Editor route chunk size +- Landing page chunk size + +#### 4. Search and recall improvements +**Why:** One of the biggest compounding opportunities is turning ResolutionFlow into team memory, not just a flow runner. + +**Good first step:** +- Search for similar sessions and prior resolutions by flow, tag, client, or ticket context + +--- + +### 3. Biggest Enterprise / Trust Upgrades + +These are the moves most likely to change how serious buyers perceive the product. + +#### 1. Evidence-rich sessions +**Add:** +- Screenshot upload/paste +- Attachments +- Command output capture +- Evidence in exports + +**Why it matters:** MSP work is proof-heavy. Evidence makes the platform feel operationally complete. + +#### 2. Smart PSA / client context +**Add:** +- Ticket details +- Client/site context +- Related recent sessions +- Asset/configuration context +- SLA metadata + +**Why it matters:** This is what reduces alt-tabbing and makes ResolutionFlow feel indispensable. + +#### 3. Queue / worker architecture +**Why:** AI tasks, indexing, imports, notifications, and integration syncs will eventually compete with request handling. + +**Likely candidates:** +- AI generation jobs +- KB imports +- Embedding/indexing +- Webhook fan-out +- Scheduled maintenance orchestration +- PDF generation + +#### 4. Buyer-facing trust surfaces +**Add:** +- Changelog +- Status page +- Security page +- Backup/export promise +- Clear onboarding docs + +**Why it matters:** Buyers infer maturity from these before they inspect the product deeply. + +--- + +## Recommended Execution Order + +### Do Now +1. Add product analytics +2. Add Playwright for core journeys +3. Add security headers and trust hardening +4. Improve empty states and professional exports + +### Do Next +1. Add smart PSA/client context in sessions +2. Add evidence-rich sessions and attachments +3. Add search/recall improvements +4. Add Web Vitals and performance budgets + +### Explore After That +1. Add queue/worker architecture +2. Expand offline/PWA support for session running +3. Add deeper RMM context integrations + +--- + +## Playwright Implementation Plan + +## Goal + +Add Playwright in a way that improves confidence quickly without creating a brittle, high-maintenance test suite. + +The right strategy is: + +- start with a small smoke suite +- prefer stable selectors and seeded users +- avoid highly dynamic AI/editor interactions in phase 1 +- run Chromium first +- only expand once the suite is reliable in CI + +--- + +## Why Playwright Fits This Stack + +ResolutionFlow is a good Playwright candidate because: + +- the frontend is a browser-heavy SPA +- route transitions and auth flows matter a lot +- many important regressions are UI integration issues, not backend unit issues +- CI already exists, so there is a natural place to add an e2e job + +--- + +## Recommended Phase 1 Scope + +Start with the least brittle, highest-signal journeys. + +### Phase 1 tests + +1. **Login smoke test** + - visit `/login` + - sign in with seeded test user + - verify redirect into authenticated app + +2. **Authenticated shell loads** + - verify sidebar/nav renders + - verify key route content appears + +3. **Session history page loads** + - navigate to `/sessions` + - verify tabs or session history shell renders + +4. **Account settings save flow** + - navigate to `/account/profile` or `/account` + - edit a safe field if possible + - verify success toast/message + +5. **Feedback form flow** + - navigate to `/feedback` + - submit a simple feedback entry + - verify success state + +6. **Shared session public page** + - only if a reliable fixture exists + - otherwise defer to phase 2 + +### Avoid in Phase 1 + +- AI chat assertions +- Monaco-heavy editor interactions +- drag-and-drop editor behavior +- cross-reference graph assertions +- timing-sensitive maintenance flows + +Those are better once the test harness is stable. + +--- + +## Test User Strategy + +Use your existing seeded local users from [seed_test_users.py](/home/michaelchihlas/dev/patherly/backend/scripts/seed_test_users.py). + +### Existing seeded accounts + +- `admin@resolutionflow.example.com` +- `pro@resolutionflow.example.com` +- `teamadmin@resolutionflow.example.com` +- `engineer@resolutionflow.example.com` + +### Shared password + +- `TestPass123!` + +### Recommended test account for phase 1 + +Use `teamadmin@resolutionflow.example.com` for most authenticated tests. + +Why: +- broad enough permissions +- less risky than binding all tests to super admin +- closer to realistic team usage + +--- + +## How To Implement Playwright + +## 1. Add dependencies + +From `frontend/`: + +```bash +npm install -D @playwright/test +npx playwright install chromium +``` + +Optional later: + +```bash +npx playwright install +``` + +That installs Firefox/WebKit too, but Chromium is the right starting point. + +--- + +## 2. Add package scripts + +Add these scripts to [frontend/package.json](/home/michaelchihlas/dev/patherly/frontend/package.json): + +```json +{ + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" + } +} +``` + +--- + +## 3. Add Playwright config + +Create [frontend/playwright.config.ts](/home/michaelchihlas/dev/patherly/frontend/playwright.config.ts). + +Recommended shape: + +```ts +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [['html'], ['list']], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 4173', + port: 4173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) +``` + +### Why `vite preview` instead of `vite dev` + +Use the built app in e2e so tests are closer to production behavior and less vulnerable to dev-server quirks. + +--- + +## 4. Add an e2e folder structure + +Recommended: + +```text +frontend/ + e2e/ + auth.spec.ts + navigation.spec.ts + feedback.spec.ts + fixtures/ + auth.ts + utils/ + session.ts +``` + +Keep helpers small. Avoid building a giant abstraction layer too early. + +--- + +## 5. Add a login helper + +Because the app stores tokens in `localStorage`, there are two valid strategies: + +### Option A: Log in through the UI +Best for the first smoke test. + +Pros: +- verifies the real login flow +- simple to understand + +Cons: +- slower when repeated across many specs + +### Option B: Log in through the API and set storage state +Best after the first smoke test works. + +Pros: +- much faster +- reduces duplicated login steps across tests + +Cons: +- does not itself verify the login form UI + +### Recommended approach + +- keep **one** UI login spec +- use **API login + saved storage state** for the rest + +Because the backend already supports `POST /api/v1/auth/login/json`, Playwright can authenticate directly. + +Example shape: + +```ts +import { request, expect } from '@playwright/test' + +export async function loginViaApi(baseApiUrl: string) { + const api = await request.newContext() + const response = await api.post(`${baseApiUrl}/api/v1/auth/login/json`, { + data: { + email: 'teamadmin@resolutionflow.example.com', + password: 'TestPass123!', + }, + }) + + expect(response.ok()).toBeTruthy() + return response.json() +} +``` + +Then inject `access_token` and `refresh_token` into localStorage before page load. + +--- + +## 6. Make selectors stable + +Playwright is easiest to maintain when the UI exposes stable selectors. + +The current UI already has some good `aria-label` coverage, which is enough for many tests. Where a flow is critical or copy is likely to change, add `data-testid`. + +### Recommended `data-testid` targets + +- login form +- login submit button +- app sidebar +- session history page shell +- feedback form +- save buttons on important settings pages + +### Rule of thumb + +- prefer `getByRole()` and `getByLabel()` first +- add `data-testid` for high-value flows where text is decorative or likely to change + +--- + +## 7. Seed data before e2e runs + +Phase 1 should not depend on manually-created accounts. + +Recommended flow: + +1. start Postgres +2. run backend migrations +3. run `python -m scripts.seed_test_users` +4. start backend +5. start frontend preview server +6. run Playwright + +If later tests need trees, add a second seed step for flows: + +```bash +python -m scripts.seed_trees +python -m scripts.seed_trees_v2 +python -m scripts.seed_procedural_flows +``` + +For the very first phase, user-seeding alone is enough if tests stay focused on auth, navigation, feedback, and settings. + +--- + +## 8. Add initial smoke specs + +### `auth.spec.ts` + +Covers: +- login page loads +- valid login succeeds +- invalid login shows error + +### `navigation.spec.ts` + +Covers: +- authenticated app shell renders +- `/sessions` loads +- `/feedback` loads +- `/account` loads + +### `feedback.spec.ts` + +Covers: +- feedback form submit +- success state visible + +Keep these small. One assertion-heavy mega-test is worse than a few short focused tests. + +--- + +## 9. Add Playwright to CI + +Your existing CI workflow is already in [.github/workflows/ci.yml](/home/michaelchihlas/dev/patherly/.github/workflows/ci.yml). Add a separate `e2e` job instead of mixing Playwright into the existing frontend unit-test job. + +### Recommended CI job shape + +1. checkout +2. set up Python +3. set up Node +4. start Postgres service +5. install backend dependencies +6. install frontend dependencies +7. run migrations +8. seed test users +9. start backend in background +10. build frontend +11. install Playwright browser +12. run Playwright against `vite preview` +13. upload Playwright report/artifacts on failure + +### Important detail + +Set `VITE_API_URL=http://127.0.0.1:8000` for the frontend build used in CI e2e. + +--- + +## Suggested CI Commands + +Backend: + +```bash +cd backend +alembic upgrade head +python -m scripts.seed_test_users +uvicorn app.main:app --host 127.0.0.1 --port 8000 & +``` + +Frontend: + +```bash +cd frontend +npm ci +npm run build +npx playwright install --with-deps chromium +npm run test:e2e +``` + +Use environment variables: + +```bash +VITE_API_URL=http://127.0.0.1:8000 +PLAYWRIGHT_BASE_URL=http://127.0.0.1:4173 +``` + +--- + +## Phase 2 Expansion + +Once the smoke suite is stable, expand into actual business-critical flows. + +### Phase 2 candidates + +1. Start and resume a session +2. Export a session +3. Create a share link +4. Open analytics pages +5. Validate account integrations page behavior + +### Phase 2.5 candidates + +1. Tree library filters +2. Fork flow flow +3. Step library browse/search +4. Public shared session experience + +### Phase 3 candidates + +1. Editor workflows +2. Procedural runner +3. Drag-and-drop interactions +4. AI-assisted workflows + +Only bring editors and AI into Playwright once the harness is already trustworthy. + +--- + +## Practical Advice For This Repo + +### Keep Playwright separate from Vitest + +Vitest should stay for: +- small component logic +- hooks +- utilities +- API client logic + +Playwright should cover: +- auth +- routing +- critical user journeys +- integration behavior + +### Don’t try to test everything + +You do not need Playwright coverage for every page. Cover the flows that: + +- affect demos +- affect activation +- affect trust +- are expensive to break + +### Start with one browser + +Chromium first. + +Only add Firefox/WebKit after the suite is stable and worth the extra runtime. + +### Prefer reliable fixture creation over brittle UI setup + +Use backend seeds and API helpers whenever possible. + +--- + +## Recommended First PR For Playwright + +Keep the first implementation intentionally small. + +### Include + +1. `@playwright/test` dependency +2. `playwright.config.ts` +3. e2e scripts in `package.json` +4. one UI login smoke test +5. one authenticated navigation smoke test +6. CI e2e job + +### Do not include yet + +- editor drag-and-drop tests +- AI flow tests +- PDF validation +- multi-browser matrix +- large helper framework + +That first PR should prove the harness works end to end. + +--- + +## Final Recommendation + +If only one quality investment gets prioritized right now, it should be: + +**Playwright + product analytics together** + +Why: + +- Playwright improves confidence in shipping +- analytics improves confidence in prioritizing + +That combination is one of the cleanest ways to make ResolutionFlow feel more professional both internally and externally. diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md new file mode 100644 index 00000000..b079a6d4 --- /dev/null +++ b/frontend/e2e/README.md @@ -0,0 +1,86 @@ +# Playwright E2E + +ResolutionFlow's Playwright suite is still intentionally lean, but it now covers the main routes and workflows most likely to break demos, onboarding, or day-one product usage. + +## What it covers + +- Public landing page +- Redirect behavior for protected routes +- UI login flow +- Authenticated dashboard shell +- Session history page +- Session history filtering +- Feedback page +- Account settings page +- Profile save flow +- Flow library search +- Start session directly from library search results +- Resume incomplete session +- Start and complete a troubleshooting session +- Export preview for a completed session +- Personal analytics page +- Team analytics page +- Shared sessions exports page +- Public shared session page + +## How auth works + +- `auth.setup.ts` logs in through the backend API using the seeded team admin user +- it writes a Playwright storage state file to `e2e/.auth/team-admin.json` +- most authenticated specs reuse that state +- `auth.spec.ts`, `public.spec.ts`, and `shared-session.spec.ts` explicitly clear storage so those tests stay truly unauthenticated + +## Local prerequisites + +1. PostgreSQL must be running +2. `backend/venv` should exist with backend dependencies installed +3. frontend dependencies must be installed +4. Playwright Chromium must be installed once + +## First-time setup + +```bash +cd frontend +npm install +npx playwright install chromium +``` + +## Run the suite + +```bash +cd frontend +npm run test:e2e +``` + +Useful variants: + +```bash +npm run test:e2e -- --list +npm run test:e2e:headed +npm run test:e2e:ui +npm run test:e2e:debug +``` + +## Backend boot behavior + +The Playwright config starts the backend automatically. It will: + +1. run Alembic migrations +2. seed test users +3. launch Uvicorn on `127.0.0.1:8000` + +Then it builds and serves the frontend via `vite preview` on `127.0.0.1:4173`. + +## Default test credentials + +- Email: `teamadmin@resolutionflow.example.com` +- Password: `TestPass123!` + +Override them with: + +- `PLAYWRIGHT_TEST_EMAIL` +- `PLAYWRIGHT_TEST_PASSWORD` + +## Common local issue + +If the suite fails before tests start with a database connection error, PostgreSQL is not running or the Playwright DB env vars do not point at a reachable database. diff --git a/frontend/e2e/analytics.spec.ts b/frontend/e2e/analytics.spec.ts new file mode 100644 index 00000000..e4aeb8b0 --- /dev/null +++ b/frontend/e2e/analytics.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { + completeSession, + createAuthenticatedApiContext, + createSession, + createTroubleshootingTree, + disposeApiContext, + uniqueName, +} from './helpers/api' + +test.describe('analytics smoke tests', () => { + test('personal and team analytics pages load for a team admin', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + name: uniqueName('Playwright Analytics Flow'), + }) + const session = await createSession(api, tree.id, { + ticket_number: `PW-ANALYTICS-${Date.now()}`, + client_name: 'Analytics Client', + }) + await completeSession(api, session.id, { + outcome: 'resolved', + outcome_notes: 'Analytics smoke test session', + }) + + try { + await page.goto('/analytics/me') + + await expect( + page.getByRole('heading', { name: 'My Analytics' }), + ).toBeVisible() + await expect(page.getByText('My Sessions', { exact: true })).toBeVisible() + + await page.goto('/analytics') + + await expect( + page.getByRole('heading', { name: 'Team Analytics' }), + ).toBeVisible() + await expect(page.getByText('Total Sessions', { exact: true })).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts new file mode 100644 index 00000000..ebccae3c --- /dev/null +++ b/frontend/e2e/auth.setup.ts @@ -0,0 +1,68 @@ +import { expect, test as setup } from '@playwright/test' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +type TokenResponse = { + access_token: string + refresh_token: string + token_type: string +} + +const frontendOrigin = new URL( + process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173', +).origin +const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000' +const testEmail = + process.env.PLAYWRIGHT_TEST_EMAIL || 'teamadmin@resolutionflow.example.com' +const testPassword = + process.env.PLAYWRIGHT_TEST_PASSWORD || 'TestPass123!' + +const authDir = fileURLToPath(new URL('./.auth/', import.meta.url)) +const authFile = path.join(authDir, 'team-admin.json') + +setup('authenticate seeded team admin and persist storage state', async ({ request }) => { + const response = await request.post(`${apiOrigin}/api/v1/auth/login/json`, { + data: { + email: testEmail, + password: testPassword, + }, + }) + + expect(response.ok()).toBeTruthy() + + const token = (await response.json()) as TokenResponse + + const authStorage = JSON.stringify({ + state: { + token, + isAuthenticated: true, + account: null, + subscription: null, + }, + version: 0, + }) + + await mkdir(authDir, { recursive: true }) + await writeFile( + authFile, + JSON.stringify( + { + cookies: [], + origins: [ + { + origin: frontendOrigin, + localStorage: [ + { name: 'access_token', value: token.access_token }, + { name: 'refresh_token', value: token.refresh_token }, + { name: 'auth-storage', value: authStorage }, + ], + }, + ], + }, + null, + 2, + ), + 'utf8', + ) +}) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 00000000..e5a261d9 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from '@playwright/test' +import { signIn } from './helpers/auth' + +test.use({ storageState: { cookies: [], origins: [] } }) + +test.describe('authentication smoke tests', () => { + test('team admin can sign in through the login form', async ({ page }) => { + await signIn(page) + + await expect(page).toHaveURL(/\/$/) + await expect(page.getByTestId('app-shell')).toBeVisible() + }) +}) diff --git a/frontend/e2e/helpers/api.ts b/frontend/e2e/helpers/api.ts new file mode 100644 index 00000000..3786f8e0 --- /dev/null +++ b/frontend/e2e/helpers/api.ts @@ -0,0 +1,196 @@ +import { expect, request as playwrightRequest, type APIRequestContext } from '@playwright/test' + +type TokenResponse = { + access_token: string + refresh_token: string + token_type: string +} + +type TreeResponse = { + id: string + name: string + description?: string | null + tree_type: string + tree_structure: Record +} + +type SessionResponse = { + id: string + tree_id: string + started_at: string | null + completed_at: string | null + ticket_number: string | null + client_name: string | null +} + +type SessionShareResponse = { + id: string + session_id: string + share_token: string + share_name: string | null + visibility: 'public' | 'account' + is_active: boolean +} + +const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000' +const testEmail = + process.env.PLAYWRIGHT_TEST_EMAIL || 'teamadmin@resolutionflow.example.com' +const testPassword = + process.env.PLAYWRIGHT_TEST_PASSWORD || 'TestPass123!' + +export async function createAuthenticatedApiContext() { + const authRequest = await playwrightRequest.newContext() + const authResponse = await authRequest.post(`${apiOrigin}/api/v1/auth/login/json`, { + data: { + email: testEmail, + password: testPassword, + }, + }) + + expect(authResponse.ok()).toBeTruthy() + + const token = (await authResponse.json()) as TokenResponse + await authRequest.dispose() + + return playwrightRequest.newContext({ + baseURL: `${apiOrigin}/api/v1/`, + extraHTTPHeaders: { + Authorization: `Bearer ${token.access_token}`, + 'Content-Type': 'application/json', + }, + }) +} + +export async function disposeApiContext(api: APIRequestContext) { + await api.dispose() +} + +export function uniqueName(prefix: string) { + return `${prefix} ${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export async function createTroubleshootingTree( + api: APIRequestContext, + overrides?: Partial<{ + name: string + description: string + question: string + answerLabel: string + alternateAnswerLabel: string + solutionTitle: string + solutionDescription: string + alternateSolutionTitle: string + alternateSolutionDescription: string + ticketNumber: string + }>, +) { + const treeName = overrides?.name || uniqueName('Playwright Troubleshooting Flow') + const question = overrides?.question || 'Can you reproduce the issue?' + const answerLabel = overrides?.answerLabel || 'Yes' + const alternateAnswerLabel = overrides?.alternateAnswerLabel || 'No' + const solutionTitle = overrides?.solutionTitle || 'Apply the known fix' + const solutionDescription = overrides?.solutionDescription || 'Run the standard remediation steps' + const alternateSolutionTitle = overrides?.alternateSolutionTitle || 'Collect more details and escalate' + const alternateSolutionDescription = + overrides?.alternateSolutionDescription || 'Escalate with the collected troubleshooting evidence' + + const response = await api.post('trees', { + data: { + name: treeName, + description: overrides?.description || 'Playwright-created troubleshooting flow', + category: 'Playwright', + tree_type: 'troubleshooting', + tree_structure: { + id: 'root', + type: 'decision', + question, + options: [ + { id: 'opt-yes', label: answerLabel, next_node_id: 'fix-step' }, + { id: 'opt-no', label: alternateAnswerLabel, next_node_id: 'escalate-step' }, + ], + children: [ + { + id: 'fix-step', + type: 'solution', + title: solutionTitle, + description: solutionDescription, + solution: 'Issue resolved with standard steps', + }, + { + id: 'escalate-step', + type: 'solution', + title: alternateSolutionTitle, + description: alternateSolutionDescription, + solution: 'Escalate to the next support tier', + }, + ], + }, + status: 'published', + }, + }) + + expect(response.ok()).toBeTruthy() + return (await response.json()) as TreeResponse +} + +export async function createSession( + api: APIRequestContext, + treeId: string, + overrides?: Partial<{ + ticket_number: string + client_name: string + }>, +) { + const response = await api.post('sessions', { + data: { + tree_id: treeId, + ticket_number: overrides?.ticket_number || `PW-${Date.now()}`, + client_name: overrides?.client_name || 'Playwright Client', + }, + }) + + expect(response.ok()).toBeTruthy() + return (await response.json()) as SessionResponse +} + +export async function completeSession( + api: APIRequestContext, + sessionId: string, + overrides?: Partial<{ + outcome: 'resolved' | 'workaround' | 'escalated' | 'unresolved' + outcome_notes: string + next_steps: string + }>, +) { + const response = await api.post(`sessions/${sessionId}/complete`, { + data: { + outcome: overrides?.outcome || 'resolved', + outcome_notes: overrides?.outcome_notes || 'Completed by Playwright', + next_steps: overrides?.next_steps || 'No follow-up required', + }, + }) + + expect(response.ok()).toBeTruthy() + return (await response.json()) as SessionResponse +} + +export async function createSessionShare( + api: APIRequestContext, + sessionId: string, + overrides?: Partial<{ + visibility: 'public' | 'account' + share_name: string + expires_at: string + }>, +) { + const response = await api.post(`sessions/${sessionId}/shares`, { + data: { + visibility: overrides?.visibility || 'public', + share_name: overrides?.share_name, + expires_at: overrides?.expires_at, + }, + }) + + expect(response.ok()).toBeTruthy() + return (await response.json()) as SessionShareResponse +} diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts new file mode 100644 index 00000000..41831390 --- /dev/null +++ b/frontend/e2e/helpers/auth.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from '@playwright/test' + +const TEST_USER_EMAIL = + process.env.PLAYWRIGHT_TEST_EMAIL || 'teamadmin@resolutionflow.example.com' +const TEST_USER_PASSWORD = + process.env.PLAYWRIGHT_TEST_PASSWORD || 'TestPass123!' + +export async function signIn(page: Page) { + await page.goto('/login') + + await expect(page.getByTestId('login-form')).toBeVisible() + await page.getByLabel('Email address').fill(TEST_USER_EMAIL) + await page.getByLabel('Password').fill(TEST_USER_PASSWORD) + await page.getByTestId('login-submit').click() + + await expect(page.getByTestId('app-shell')).toBeVisible() +} diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts new file mode 100644 index 00000000..fb82ce00 --- /dev/null +++ b/frontend/e2e/history.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' +import { + createAuthenticatedApiContext, + createSession, + createTroubleshootingTree, + disposeApiContext, + uniqueName, +} from './helpers/api' + +test.describe('session history smoke tests', () => { + test('can filter sessions by ticket and client, then open details', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const ticketNumber = `PW-HISTORY-${Date.now()}` + const clientName = uniqueName('History Client') + const tree = await createTroubleshootingTree(api, { + name: uniqueName('Playwright History Flow'), + }) + const session = await createSession(api, tree.id, { + ticket_number: ticketNumber, + client_name: clientName, + }) + + try { + await page.goto('/sessions') + + await expect( + page.getByRole('heading', { name: 'Session History' }), + ).toBeVisible() + + await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber) + await page.getByPlaceholder('Search by client name...').fill(clientName) + + const sessionCard = page.locator('.bg-card').filter({ hasText: ticketNumber }).filter({ hasText: clientName }).first() + await expect(sessionCard).toBeVisible() + await expect(sessionCard.getByText(tree.name)).toBeVisible() + + await sessionCard.getByRole('button', { name: 'View Details' }).click() + + await expect(page).toHaveURL(new RegExp(`/sessions/${session.id}$`)) + await expect(page.getByRole('heading', { name: ticketNumber })).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/library-start.spec.ts b/frontend/e2e/library-start.spec.ts new file mode 100644 index 00000000..6ab716cb --- /dev/null +++ b/frontend/e2e/library-start.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { + createAuthenticatedApiContext, + createTroubleshootingTree, + disposeApiContext, + uniqueName, +} from './helpers/api' + +test.describe('flow library start-session smoke tests', () => { + test('can start a troubleshooting session directly from a search result', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + name: uniqueName('Playwright Direct Start Flow'), + question: 'Did the library launch open the flow?', + }) + + try { + await page.goto('/trees') + + await expect( + page.getByRole('heading', { name: 'Flow Library' }), + ).toBeVisible() + + await page.getByPlaceholder('Search flows...').fill(tree.name) + await page.getByRole('button', { name: 'Search', exact: true }).click() + + const treeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first() + await expect(treeCard).toBeVisible() + await treeCard.getByRole('button', { name: /^Start(?: Session)?$/ }).click() + + await expect(page).toHaveURL(new RegExp(`/trees/${tree.id}/navigate$`)) + await expect(page.getByRole('heading', { name: tree.name })).toBeVisible() + await expect(page.getByRole('button', { name: 'Start Troubleshooting' })).toBeVisible() + + await page.getByPlaceholder('e.g., INC0012345').fill('PW-DIRECT-START') + await page.getByPlaceholder('e.g., Acme Corp').fill('Direct Start Client') + await page.getByRole('button', { name: 'Start Troubleshooting' }).click() + + await expect( + page.getByRole('heading', { name: 'Did the library launch open the flow?' }), + ).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/library.spec.ts b/frontend/e2e/library.spec.ts new file mode 100644 index 00000000..04d694c0 --- /dev/null +++ b/frontend/e2e/library.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test' +import { + createAuthenticatedApiContext, + createTroubleshootingTree, + disposeApiContext, +} from './helpers/api' + +test.describe('flow library smoke tests', () => { + test('can search for an API-created troubleshooting flow', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api) + + try { + await page.goto('/trees') + + await expect( + page.getByRole('heading', { name: 'Flow Library' }), + ).toBeVisible() + + await page.getByPlaceholder('Search flows...').fill(tree.name) + await page.getByRole('button', { name: 'Search', exact: true }).click() + + await expect(page.getByText(tree.name)).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts new file mode 100644 index 00000000..621fc367 --- /dev/null +++ b/frontend/e2e/navigation.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test' + +test.describe('authenticated navigation smoke tests', () => { + test('dashboard loads for an authenticated user', async ({ page }) => { + await page.goto('/') + + await expect(page.getByTestId('app-shell')).toBeVisible() + await expect( + page.getByRole('heading', { name: /Good (morning|afternoon|evening)/ }), + ).toBeVisible() + }) + + test('session history page loads', async ({ page }) => { + await page.goto('/sessions') + + await expect( + page.getByRole('heading', { name: 'Session History' }), + ).toBeVisible() + }) + + test('feedback page loads', async ({ page }) => { + await page.goto('/feedback') + + await expect( + page.getByRole('heading', { name: 'Send Feedback' }), + ).toBeVisible() + }) + + test('account settings page loads', async ({ page }) => { + await page.goto('/account') + + await expect( + page.getByRole('heading', { name: 'Account Settings' }), + ).toBeVisible() + }) +}) diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts new file mode 100644 index 00000000..379d7b95 --- /dev/null +++ b/frontend/e2e/profile.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { uniqueName } from './helpers/api' + +test.describe('profile settings smoke tests', () => { + test('can update the job title', async ({ page }) => { + const jobTitle = uniqueName('Playwright Engineer') + + await page.goto('/account/profile') + + await expect( + page.getByRole('heading', { name: 'Profile Settings' }), + ).toBeVisible() + + await page.getByLabel('Job Title').fill(jobTitle) + await page.getByRole('button', { name: 'Save Changes' }).click() + + await expect(page.getByText('Profile updated')).toBeVisible() + await expect(page.getByLabel('Job Title')).toHaveValue(jobTitle) + }) +}) diff --git a/frontend/e2e/public.spec.ts b/frontend/e2e/public.spec.ts new file mode 100644 index 00000000..9b045c1b --- /dev/null +++ b/frontend/e2e/public.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' + +test.use({ storageState: { cookies: [], origins: [] } }) + +test.describe('public route smoke tests', () => { + test('landing page loads', async ({ page }) => { + await page.goto('/landing') + + await expect( + page.getByRole('link', { name: 'Get Started Free' }), + ).toBeVisible() + await expect( + page.getByText('Stop writing ticket notes.'), + ).toBeVisible() + }) + + test('protected routes redirect unauthenticated users to landing', async ({ page }) => { + await page.goto('/sessions') + + await expect(page).toHaveURL(/\/landing$/) + await expect( + page.getByRole('link', { name: 'Sign In' }), + ).toBeVisible() + }) +}) diff --git a/frontend/e2e/resume.spec.ts b/frontend/e2e/resume.spec.ts new file mode 100644 index 00000000..8d51a9d1 --- /dev/null +++ b/frontend/e2e/resume.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' +import { + createAuthenticatedApiContext, + createSession, + createTroubleshootingTree, + disposeApiContext, +} from './helpers/api' + +test.describe('session resume smoke tests', () => { + test('can resume an incomplete session from the library page', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + question: 'Is the affected service still down?', + }) + await createSession(api, tree.id, { + ticket_number: 'PW-RESUME', + client_name: 'Resume Client', + }) + + try { + await page.goto('/trees') + + const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).filter({ hasText: 'Resume' }).first() + await expect(resumeCard).toBeVisible() + await resumeCard.getByRole('button', { name: 'Resume' }).first().click() + + await expect(page).toHaveURL(new RegExp(`/trees/${tree.id}/navigate`)) + await expect( + page.getByRole('heading', { name: tree.name }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Is the affected service still down?' }), + ).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/session-workflow.spec.ts b/frontend/e2e/session-workflow.spec.ts new file mode 100644 index 00000000..52198a38 --- /dev/null +++ b/frontend/e2e/session-workflow.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test' +import { + completeSession, + createAuthenticatedApiContext, + createSession, + createTroubleshootingTree, + disposeApiContext, +} from './helpers/api' + +test.describe('session workflow smoke tests', () => { + test('can start and complete a troubleshooting session through the UI', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + question: 'Did the restart resolve the issue?', + answerLabel: 'Yes, it is fixed', + solutionTitle: 'Document the fix and close the ticket', + }) + + try { + await page.goto(`/trees/${tree.id}/navigate`) + + await expect( + page.getByRole('heading', { name: tree.name }), + ).toBeVisible() + + await page.getByPlaceholder('e.g., INC0012345').fill('PW-START-COMPLETE') + await page.getByPlaceholder('e.g., Acme Corp').fill('Workflow Client') + await page.getByRole('button', { name: 'Start Troubleshooting' }).click() + + await expect( + page.getByRole('heading', { name: 'Did the restart resolve the issue?' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Yes, it is fixed' }).click() + + await expect( + page.getByRole('heading', { name: 'Document the fix and close the ticket' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Complete Session' }).click() + + const outcomeDialog = page.getByRole('dialog', { name: 'Session Outcome' }) + await expect(outcomeDialog).toBeVisible() + await outcomeDialog.getByPlaceholder('Add context for this outcome...').fill('Playwright verified the UI completion flow.') + await outcomeDialog.getByRole('button', { name: 'Complete Session' }).click() + + const csatDialog = page.getByRole('dialog', { name: 'How was this flow?' }) + await expect(csatDialog).toBeVisible() + await csatDialog.getByRole('button', { name: 'Skip' }).click() + + await expect(page).toHaveURL(/\/sessions\/[0-9a-f-]+$/) + await expect(page.getByText('Resolved')).toBeVisible() + await expect(page.getByText('PW-START-COMPLETE')).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) + + test('can preview an export for a completed session', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + name: 'Playwright Export Flow', + }) + const session = await createSession(api, tree.id, { + ticket_number: 'PW-EXPORT', + client_name: 'Export Client', + }) + await completeSession(api, session.id, { + outcome_notes: 'Completed for export preview verification', + }) + + try { + await page.goto(`/sessions/${session.id}`) + + await expect( + page.getByRole('heading', { name: 'PW-EXPORT' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Preview' }).click() + + const previewDialog = page.getByRole('dialog', { name: 'Export Preview' }) + await expect(previewDialog).toBeVisible() + await expect(previewDialog.getByLabel('Export content')).not.toHaveValue('') + await expect(previewDialog.getByLabel('Export content')).toHaveValue(/PW-EXPORT/) + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/shared-session.spec.ts b/frontend/e2e/shared-session.spec.ts new file mode 100644 index 00000000..d168c175 --- /dev/null +++ b/frontend/e2e/shared-session.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { + completeSession, + createAuthenticatedApiContext, + createSession, + createSessionShare, + createTroubleshootingTree, + disposeApiContext, + uniqueName, +} from './helpers/api' + +test.use({ storageState: { cookies: [], origins: [] } }) + +test.describe('shared session smoke tests', () => { + test('a public share opens for an unauthenticated viewer', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + name: uniqueName('Playwright Shared Session Flow'), + }) + const session = await createSession(api, tree.id, { + ticket_number: `PW-SHARE-${Date.now()}`, + client_name: 'Shared Session Client', + }) + await completeSession(api, session.id, { + outcome: 'resolved', + outcome_notes: 'Shared session smoke test', + }) + const share = await createSessionShare(api, session.id, { + visibility: 'public', + share_name: 'Playwright Customer Share', + }) + + try { + await page.goto(`/share/${share.share_token}`) + + await expect( + page.getByRole('heading', { name: 'Playwright Customer Share' }), + ).toBeVisible() + await expect(page.getByText('Can you reproduce the issue?', { exact: true })).toBeVisible() + await expect(page.getByText(`Ticket: #${session.ticket_number}`)).toBeVisible() + await expect(page.getByText(`Client: ${session.client_name}`)).toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/e2e/shares.spec.ts b/frontend/e2e/shares.spec.ts new file mode 100644 index 00000000..78237c0f --- /dev/null +++ b/frontend/e2e/shares.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { + createAuthenticatedApiContext, + createSession, + createSessionShare, + createTroubleshootingTree, + disposeApiContext, + uniqueName, +} from './helpers/api' + +test.describe('shared session management smoke tests', () => { + test('created shares appear in exports and can be revoked', async ({ page }) => { + const api = await createAuthenticatedApiContext() + const tree = await createTroubleshootingTree(api, { + name: uniqueName('Playwright Exports Flow'), + }) + const session = await createSession(api, tree.id, { + ticket_number: `PW-EXPORTS-${Date.now()}`, + client_name: 'Exports Client', + }) + const share = await createSessionShare(api, session.id, { + visibility: 'public', + share_name: uniqueName('Playwright Exports Share'), + }) + + try { + await page.goto('/shares') + + await expect( + page.getByRole('heading', { name: 'My Shared Sessions' }), + ).toBeVisible() + await expect(page.getByText(share.share_name || '')).toBeVisible() + + const shareCard = page.locator('.bg-card').filter({ hasText: share.share_name || '' }).first() + await shareCard.getByRole('button', { name: 'Revoke' }).click() + + const confirmDialog = page.getByRole('dialog', { name: 'Revoke Share Link' }) + await expect(confirmDialog).toBeVisible() + await confirmDialog.getByRole('button', { name: 'Revoke' }).click() + + await expect(page.getByText(share.share_name || '')).not.toBeVisible() + } finally { + await disposeApiContext(api) + } + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7c76c8b8..978e0693 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.55.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -1322,6 +1323,21 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -6505,6 +6521,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6fe20a73..6f50bde4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=20.19.0 <21 || >=22.12.0" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -10,6 +13,10 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", "analyze": "vite-bundle-visualizer" }, "dependencies": { @@ -43,6 +50,7 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@eslint/js": "^9.39.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..9a0a7efe --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,67 @@ +import { defineConfig, devices } from '@playwright/test' + +const frontendBaseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173' +const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000' +const authStorageStatePath = './e2e/.auth/team-admin.json' +const backendDatabaseUrl = + process.env.PLAYWRIGHT_DATABASE_URL || 'postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/patherly' +const backendDatabaseUrlSync = + process.env.PLAYWRIGHT_DATABASE_URL_SYNC || 'postgresql://postgres:postgres@127.0.0.1:5432/patherly' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL: frontendBaseUrl, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: [ + { + command: 'PYTHON_BIN=./venv/bin/python; if [ ! -x "$PYTHON_BIN" ]; then PYTHON_BIN=python; fi; "$PYTHON_BIN" -m alembic upgrade head && "$PYTHON_BIN" -m scripts.seed_test_users && "$PYTHON_BIN" -m uvicorn app.main:app --host 127.0.0.1 --port 8000', + url: `${apiOrigin}/health`, + cwd: '../backend', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + DEBUG: process.env.PLAYWRIGHT_DEBUG || 'true', + SECRET_KEY: process.env.PLAYWRIGHT_SECRET_KEY || 'playwright-test-secret-key', + DATABASE_URL: backendDatabaseUrl, + DATABASE_URL_SYNC: backendDatabaseUrlSync, + CORS_ORIGINS: '["http://127.0.0.1:4173","http://localhost:4173","http://127.0.0.1:5173","http://localhost:5173"]', + }, + }, + { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: frontendBaseUrl, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + VITE_API_URL: apiOrigin, + VITE_SENTRY_DSN: process.env.VITE_SENTRY_DSN || '', + }, + }, + ], + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + { + name: 'chromium', + dependencies: ['setup'], + testIgnore: /.*\.setup\.ts/, + use: { + ...devices['Desktop Chrome'], + storageState: authStorageStatePath, + }, + }, + ], +}) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 848ba746..7331de74 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -89,7 +89,10 @@ export function AppLayout() { }} /> -
+
{/* Top Bar - spans full width */} diff --git a/frontend/src/components/scripts/PowerShellHighlighter.tsx b/frontend/src/components/scripts/PowerShellHighlighter.tsx index cfa80677..faf5c2e9 100644 --- a/frontend/src/components/scripts/PowerShellHighlighter.tsx +++ b/frontend/src/components/scripts/PowerShellHighlighter.tsx @@ -68,11 +68,11 @@ interface Props { export function PowerShellHighlighter({ script, className }: Props) { const parts: React.ReactNode[] = [] let lastIndex = 0 + const tokenRegex = new RegExp(TOKEN_REGEX.source, TOKEN_REGEX.flags) - TOKEN_REGEX.lastIndex = 0 let match: RegExpExecArray | null - while ((match = TOKEN_REGEX.exec(script)) !== null) { + while ((match = tokenRegex.exec(script)) !== null) { if (match.index > lastIndex) { parts.push(script.slice(lastIndex, match.index)) } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index b2e10059..deffe142 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -86,7 +86,7 @@ export function LoginPage() {

-
+
{(error || localError) && (
@@ -146,6 +146,7 @@ export function LoginPage() {