Add Playwright e2e coverage and Node 20 pin
This commit is contained in:
68
.github/workflows/ci.yml
vendored
68
.github/workflows/ci.yml
vendored
@@ -94,3 +94,71 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cd frontend && npm run 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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -216,6 +216,9 @@ __marimo__/
|
|||||||
# Temp/generated files
|
# Temp/generated files
|
||||||
backend/test_results.txt
|
backend/test_results.txt
|
||||||
frontend/stats.html
|
frontend/stats.html
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/e2e/.auth/
|
||||||
|
|
||||||
# Railway CLI (local tooling)
|
# Railway CLI (local tooling)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: resolutionflow
|
POSTGRES_DB: resolutionflow
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- rf_postgres_data:/var/lib/postgresql/data
|
- rf_postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
- AI_PROVIDER=anthropic
|
- AI_PROVIDER=anthropic
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -61,4 +61,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rf_postgres_data:
|
rf_postgres_data:
|
||||||
|
|
||||||
|
|||||||
682
docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md
Normal file
682
docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md
Normal file
@@ -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.
|
||||||
86
frontend/e2e/README.md
Normal file
86
frontend/e2e/README.md
Normal file
@@ -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.
|
||||||
44
frontend/e2e/analytics.spec.ts
Normal file
44
frontend/e2e/analytics.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
68
frontend/e2e/auth.setup.ts
Normal file
68
frontend/e2e/auth.setup.ts
Normal file
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
13
frontend/e2e/auth.spec.ts
Normal file
13
frontend/e2e/auth.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
196
frontend/e2e/helpers/api.ts
Normal file
196
frontend/e2e/helpers/api.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
17
frontend/e2e/helpers/auth.ts
Normal file
17
frontend/e2e/helpers/auth.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
45
frontend/e2e/history.spec.ts
Normal file
45
frontend/e2e/history.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
46
frontend/e2e/library-start.spec.ts
Normal file
46
frontend/e2e/library-start.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
28
frontend/e2e/library.spec.ts
Normal file
28
frontend/e2e/library.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
36
frontend/e2e/navigation.spec.ts
Normal file
36
frontend/e2e/navigation.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
20
frontend/e2e/profile.spec.ts
Normal file
20
frontend/e2e/profile.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
25
frontend/e2e/public.spec.ts
Normal file
25
frontend/e2e/public.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
38
frontend/e2e/resume.spec.ts
Normal file
38
frontend/e2e/resume.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
89
frontend/e2e/session-workflow.spec.ts
Normal file
89
frontend/e2e/session-workflow.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
46
frontend/e2e/shared-session.spec.ts
Normal file
46
frontend/e2e/shared-session.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
46
frontend/e2e/shares.spec.ts
Normal file
46
frontend/e2e/shares.spec.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -39,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@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"
|
"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": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -6505,6 +6521,50 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 <21 || >=22.12.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -10,6 +13,10 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"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"
|
"analyze": "vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -43,6 +50,7 @@
|
|||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
67
frontend/playwright.config.ts
Normal file
67
frontend/playwright.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -89,7 +89,10 @@ export function AppLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={cn('app-shell relative z-1', sidebarCollapsed && 'app-shell--collapsed')}>
|
<div
|
||||||
|
className={cn('app-shell relative z-1', sidebarCollapsed && 'app-shell--collapsed')}
|
||||||
|
data-testid="app-shell"
|
||||||
|
>
|
||||||
{/* Top Bar - spans full width */}
|
{/* Top Bar - spans full width */}
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ interface Props {
|
|||||||
export function PowerShellHighlighter({ script, className }: Props) {
|
export function PowerShellHighlighter({ script, className }: Props) {
|
||||||
const parts: React.ReactNode[] = []
|
const parts: React.ReactNode[] = []
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
|
const tokenRegex = new RegExp(TOKEN_REGEX.source, TOKEN_REGEX.flags)
|
||||||
|
|
||||||
TOKEN_REGEX.lastIndex = 0
|
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
while ((match = TOKEN_REGEX.exec(script)) !== null) {
|
while ((match = tokenRegex.exec(script)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(script.slice(lastIndex, match.index))
|
parts.push(script.slice(lastIndex, match.index))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
|
||||||
<div className="glass-card-static p-6 space-y-4">
|
<div className="glass-card-static p-6 space-y-4">
|
||||||
{(error || localError) && (
|
{(error || localError) && (
|
||||||
<div className="rounded-[10px] border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-400">
|
<div className="rounded-[10px] border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-400">
|
||||||
@@ -146,6 +146,7 @@ export function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
data-testid="login-submit"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-[10px] px-4 py-2.5 text-sm font-semibold',
|
'w-full rounded-[10px] px-4 py-2.5 text-sm font-semibold',
|
||||||
'bg-gradient-brand text-brand-dark shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
'bg-gradient-brand text-brand-dark shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
||||||
|
|||||||
Reference in New Issue
Block a user