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
|
||||
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
|
||||
backend/test_results.txt
|
||||
frontend/stats.html
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/e2e/.auth/
|
||||
|
||||
# Railway CLI (local tooling)
|
||||
node_modules/
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- rf_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
- AI_PROVIDER=anthropic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY}
|
||||
- CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
- CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
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": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -1322,6 +1323,21 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
@@ -6505,6 +6521,50 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <21 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -10,6 +13,10 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"analyze": "vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,6 +50,7 @@
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
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 */}
|
||||
<TopBar />
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@ interface Props {
|
||||
export function PowerShellHighlighter({ script, className }: Props) {
|
||||
const parts: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
const tokenRegex = new RegExp(TOKEN_REGEX.source, TOKEN_REGEX.flags)
|
||||
|
||||
TOKEN_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = TOKEN_REGEX.exec(script)) !== null) {
|
||||
while ((match = tokenRegex.exec(script)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(script.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function LoginPage() {
|
||||
</p>
|
||||
</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">
|
||||
{(error || localError) && (
|
||||
<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
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
data-testid="login-submit"
|
||||
className={cn(
|
||||
'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]',
|
||||
|
||||
Reference in New Issue
Block a user