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:
2026-05-28 14:42:31 -04:00
parent 1acc780359
commit 6937bcaabd
2 changed files with 236 additions and 15 deletions

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