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

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

View File

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

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 */}
<TopBar />

View File

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

View File

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