test(l1): E2E Playwright suite + seed L1 + coverage engineer test users
l1-workspace.spec.ts covers: - L1 user lands on /l1, intakes a problem, takes notes (autosave), resolves - L1 cannot access /pilot, /trees/new, /escalations (route guards) - Engineer with can_cover_l1 sees the L1 Workspace nav + coverage banner - escalate-without-walk path via direct API call returns escalated session Seed script adds l1@resolutionflow.example.com (l1_tech) and engineer-coverage@resolutionflow.example.com (engineer + can_cover_l1). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
189
frontend/e2e/l1-workspace.spec.ts
Normal file
189
frontend/e2e/l1-workspace.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* E2E tests for the L1 Workspace surface (Phase 1).
|
||||
*
|
||||
* Covers:
|
||||
* 1. L1 user lands on /l1 after login and can start an ad-hoc walk, take
|
||||
* notes (autosave), and resolve the session.
|
||||
* 2. L1 user cannot access /pilot, /trees/new, or /escalations — route
|
||||
* guards bounce them back to /.
|
||||
* 3. Engineer with can_cover_l1=true sees the "L1 Workspace" nav entry and
|
||||
* the "You're covering L1" banner.
|
||||
* 4. escalate-without-walk API endpoint returns an escalated adhoc session
|
||||
* when called from an authenticated L1 user.
|
||||
*
|
||||
* Seed users (added by seed_test_users.py):
|
||||
* l1@resolutionflow.example.com — account_role=l1_tech
|
||||
* engineer-coverage@resolutionflow.example.com — engineer + can_cover_l1
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// These tests always log in fresh — no shared storageState from auth.setup.ts.
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
const L1_EMAIL = 'l1@resolutionflow.example.com'
|
||||
const COVERAGE_EMAIL = 'engineer-coverage@resolutionflow.example.com'
|
||||
const PASSWORD = 'TestPass123!'
|
||||
|
||||
const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000'
|
||||
|
||||
/**
|
||||
* Log in via the login form using exact test-IDs / labels that LoginPage uses.
|
||||
* Uses data-testid="login-form", getByLabel('Email address'), getByLabel('Password'),
|
||||
* and data-testid="login-submit" — matching the actual LoginPage.tsx markup.
|
||||
*/
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await expect(page.getByTestId('login-form')).toBeVisible()
|
||||
await page.getByLabel('Email address').fill(email)
|
||||
await page.getByLabel('Password').fill(PASSWORD)
|
||||
await page.getByTestId('login-submit').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a bearer token for the given email via the JSON login endpoint.
|
||||
* Used for direct API assertions without going through the browser.
|
||||
*/
|
||||
async function getToken(
|
||||
page: Page,
|
||||
email: string,
|
||||
): Promise<string> {
|
||||
const response = await page.request.post(`${apiOrigin}/api/v1/auth/login/json`, {
|
||||
data: { email, password: PASSWORD },
|
||||
})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const body = (await response.json()) as { access_token: string }
|
||||
return body.access_token
|
||||
}
|
||||
|
||||
test.describe('L1 Workspace', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1: Happy path — login → /l1 → start walk → notes → resolve
|
||||
// -------------------------------------------------------------------------
|
||||
test('L1 user lands on /l1 after login and can intake, take notes, and resolve', async ({ page }) => {
|
||||
await login(page, L1_EMAIL)
|
||||
|
||||
// ProtectedRoute redirects l1_tech from / → /l1
|
||||
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
|
||||
|
||||
// Greeting heading: "Good morning|afternoon|evening, <name>."
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Good (morning|afternoon|evening)/i }),
|
||||
).toBeVisible()
|
||||
|
||||
// Fill in problem statement textarea
|
||||
const problemTextarea = page.getByPlaceholder("What's the user calling about?")
|
||||
await expect(problemTextarea).toBeVisible()
|
||||
await problemTextarea.fill('Customer says Outlook is broken after the latest update')
|
||||
|
||||
// Click "Start walk →" button
|
||||
await page.getByRole('button', { name: /Start walk/i }).click()
|
||||
|
||||
// Should navigate to /l1/walk/<uuid>
|
||||
await expect(page).toHaveURL(/\/l1\/walk\//, { timeout: 10_000 })
|
||||
|
||||
// The header badge shows "Ad-hoc walk"
|
||||
await expect(page.getByText('Ad-hoc walk')).toBeVisible()
|
||||
|
||||
// Take notes in the walk textarea
|
||||
const notesTextarea = page.getByPlaceholder(
|
||||
'What did the customer say? What did you check? What did you try?',
|
||||
)
|
||||
await expect(notesTextarea).toBeVisible()
|
||||
await notesTextarea.fill('Walked customer through closing and reopening Outlook — issue resolved')
|
||||
|
||||
// Autosave fires after 300ms debounce; wait up to 5s for the "Saved Xs ago" indicator
|
||||
await expect(
|
||||
page.getByText(/Saved \d+s ago|Saving…/i),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Open the Resolve modal
|
||||
await page.getByRole('button', { name: /Resolve/i }).click()
|
||||
|
||||
// Modal heading: "Did this resolve it?"
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Did this resolve it?' }),
|
||||
).toBeVisible()
|
||||
|
||||
// Click "Yes"
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Fill resolution notes
|
||||
await page.getByPlaceholder('Resolution notes…').fill('Fixed via restarting Outlook')
|
||||
|
||||
// Confirm
|
||||
await page.getByRole('button', { name: 'Confirm' }).click()
|
||||
|
||||
// After resolution, onDone() navigates back to /l1
|
||||
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2: Route guard — L1 user cannot access engineer-only routes
|
||||
// -------------------------------------------------------------------------
|
||||
test('L1 user cannot access /pilot, /trees/new, or /escalations', async ({ page }) => {
|
||||
await login(page, L1_EMAIL)
|
||||
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
|
||||
|
||||
// /pilot — ProtectedRoute requires at least engineer rank; l1_tech gets bounced
|
||||
await page.goto('/pilot')
|
||||
await expect(page).not.toHaveURL(/\/pilot/, { timeout: 5_000 })
|
||||
|
||||
// /trees/new — same guard
|
||||
await page.goto('/trees/new')
|
||||
await expect(page).not.toHaveURL(/\/trees\/new/, { timeout: 5_000 })
|
||||
|
||||
// /escalations — if this route exists with a role guard it should bounce too
|
||||
await page.goto('/escalations')
|
||||
await expect(page).not.toHaveURL(/\/escalations/, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3: Coverage engineer sees the L1 nav link and the coverage banner
|
||||
// -------------------------------------------------------------------------
|
||||
test('Engineer with can_cover_l1 sees the L1 Workspace nav and coverage banner', async ({ page }) => {
|
||||
await login(page, COVERAGE_EMAIL)
|
||||
|
||||
// Coverage engineer is not l1_tech — they land on the normal workspace root
|
||||
await expect(page.getByTestId('app-shell')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Sidebar should show "L1 Workspace" link
|
||||
const l1NavLink = page.getByRole('link', { name: /L1 Workspace/i })
|
||||
await expect(l1NavLink).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Navigate to /l1
|
||||
await l1NavLink.click()
|
||||
await expect(page).toHaveURL(/\/l1/, { timeout: 10_000 })
|
||||
|
||||
// L1CoverageBanner renders: "You're covering L1. Actions logged as coverage."
|
||||
await expect(
|
||||
page.getByText(/You're covering L1/i),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4: escalate-without-walk endpoint — direct API assertion
|
||||
// -------------------------------------------------------------------------
|
||||
test('escalate-without-walk returns an escalated adhoc session', async ({ page }) => {
|
||||
const token = await getToken(page, L1_EMAIL)
|
||||
|
||||
const response = await page.request.post(
|
||||
`${apiOrigin}/api/v1/l1/escalate-without-walk`,
|
||||
{
|
||||
data: {
|
||||
problem_statement: 'Customer issue with no KB content available',
|
||||
reason_category: 'No KB available',
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status()).toBe(200)
|
||||
const body = (await response.json()) as {
|
||||
status: string
|
||||
session_kind: string
|
||||
}
|
||||
expect(body.status).toBe('escalated')
|
||||
expect(body.session_kind).toBe('adhoc')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user