Phase 2A routes in-category problems (keyword fallback matches 'outlook' → email_outlook_client) to an AI-build walk, so the old Outlook fixture never reached the ad-hoc badge. Use a custom-LOB problem and click through the out-of-scope 'Walk it ad-hoc' fallback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
195 lines
7.8 KiB
TypeScript
195 lines
7.8 KiB
TypeScript
/**
|
|
* 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. The problem must NOT keyword-match
|
|
// any DEFAULT_L1_CATEGORIES token (Phase 2A routes in-category problems to
|
|
// an AI-build walk, not ad-hoc) — a custom LOB app is out of scope.
|
|
const problemTextarea = page.getByPlaceholder("What's the user calling about?")
|
|
await expect(problemTextarea).toBeVisible()
|
|
await problemTextarea.fill('Custom LOB billing app crashes on launch for one user')
|
|
|
|
// Click "Start walk →" button
|
|
await page.getByRole('button', { name: /Start walk/i }).click()
|
|
|
|
// Out-of-scope prompt offers the free-form fallback — take it
|
|
await page.getByRole('button', { name: /Walk it ad-hoc/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')
|
|
})
|
|
})
|