Add Playwright e2e coverage and Node 20 pin

This commit is contained in:
chihlasm
2026-03-16 02:28:04 -04:00
parent 357f8e2d08
commit e39819f8d0
27 changed files with 1743 additions and 7 deletions

View File

@@ -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
View File

@@ -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/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19.0

View File

@@ -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:

View 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 apps 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
### Dont 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
View 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.

View 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)
}
})
})

View 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
View 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
View 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
}

View 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()
}

View 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)
}
})
})

View 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)
}
})
})

View 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)
}
})
})

View 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()
})
})

View 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)
})
})

View 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()
})
})

View 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)
}
})
})

View 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)
}
})
})

View 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)
}
})
})

View 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)
}
})
})

View File

@@ -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",

View File

@@ -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",

View 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,
},
},
],
})

View File

@@ -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 />

View File

@@ -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))
} }

View File

@@ -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]',