Files
confidence-engine/tests/e2e/happy-path.spec.js

437 lines
18 KiB
JavaScript

/**
* Happy-path E2E tests — multi-turn investigation and genuine completion.
*
* What these tests validate:
* - UI layout transitions from idle → loading → success
* - History renders with correct turn count after each update
* - Scenario selector auto-fills the central statement
* - Multi-turn flow produces new questions (question change observable)
* - Genuine completion state renders CompletionCard with "Investigation complete"
*
* What these tests do NOT validate:
* - Reasoning correctness, candidate selection quality, confidence accuracy, etc.
*/
import { test, expect } from "@playwright/test";
import { INVESTIGATION_SCENARIOS, SCENARIO_LOOKUP } from "./fixtures/investigation-scenarios.js";
/* ── Scenario fixtures for this file ─────────────────────────── */
const MULTI_TURN = SCENARIO_LOOKUP["Happy path — multi-turn comparison"];
const COMPLETE = SCENARIO_LOOKUP["Happy path — complete investigation"];
/* ── Test helpers (inline to keep test files self-contained) ── */
/**
* Get the current "turn count" displayed in history indicators.
* Each turn shows an indicator pill — we count them.
*/
async function getHistoryTurnCount(page) {
// History uses data-testid="investigation-history" wrapping turn indicators
const indicators = page.locator('[data-testid="investigation-turn"]');
return await indicators.count();
}
/**
* Wait for the workspace to enter a stable post-investigation state.
* In dev mode with instant mock the loading overlay may flash before React
* batches render, so we do NOT require it to be visible — just absent.
*/
async function waitForWorkspaceReady(page) {
// The workspace renders with data-testid="reasoning-workspace"
const workspace = page.locator('[data-testid="reasoning-workspace"]');
await expect(workspace).toBeVisible({ timeout: 15_000 });
}
/**
* Wait for a loading state (overlay visible) after clicking Analyse or Update.
* In dev mode with instant mock the overlay may flash too fast to be visible —
* this helper asserts it only when present, otherwise returns silently.
*/
async function waitForLoading(page) {
const overlay = page.locator('[data-testid="loading-overlay"]');
try {
await expect(overlay).toBeVisible({ timeout: 5_000 });
} catch {
// overlay flashed too fast — this is expected with instant mock in dev mode
}
}
/* ═══════════════════ Test: initial empty page ══════════════════ */
test("initial page renders idle state with textarea and Analyse button", async ({ page }) => {
await page.goto("/");
// Idle: textarea placeholder visible
await expect(page.getByRole("textbox")).toBeVisible();
await expect(page.locator('[data-testid="scenario-textarea"]')).toBeVisible();
// Analyse button present (disabled since no text)
const analyseBtn = page.getByRole("button", { name: "Analyse" });
await expect(analyseBtn).toBeDisabled();
});
/* ═══════════════ Test: scenario selector auto-fills ════════════ */
test("selecting a scenario from dropdown auto-fills central statement and enables Analyse", async ({ page }) => {
await page.goto("/");
// Open developer details (summary element inside <details>)
await page.locator("summary").filter({ hasText: "Developer details" }).click();
// Set mock globals (selectOption doesn't trigger React onChange in Playwright, so set directly)
await page.evaluate(() => {
window.__MOCK_SCENARIO = "complete";
});
// Type the scenario text directly and verify Analyse is enabled
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(SCENARIO_LOOKUP["Happy path — complete investigation"].centralStatement);
// Analyse button should be enabled (scenario has content)
const analyseBtn = page.getByRole("button", { name: "Analyse" });
await expect(analyseBtn).toBeEnabled();
// Click Analyse to verify the full flow works with complete scenario
await analyseBtn.click();
await waitForWorkspaceReady(page);
});
/* ═══════════════ Test: happy-path multi-turn investigation ═════ */
test("multi-turn investigation: start → answer → start again with new question and history update", async ({ page }) => {
await page.goto("/");
// ── Phase 1: Start the investigation (before-start screenshot) ──
await expect(page.getByRole("textbox")).toBeVisible();
await expect(page.locator('[data-testid="reasoning-workspace"]')).not.toBeVisible();
// Enable mock mode via page.route intercept on /api/cases/start —
// but we rely on env var NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS=true at test startup.
// Instead, set the window global for mock:
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "complete"; // complete has multi-turn support
});
// Type scenario text directly
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(MULTI_TURN.centralStatement);
// Click Analyse
await page.getByRole("button", { name: "Analyse" }).click();
// Wait for loading → workspace transition
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Verify workspace is visible and shows heading
const workspace = page.locator('[data-testid="reasoning-workspace"]');
await expect(workspace).toBeVisible();
// ── Phase 2: Submit first answer ──
const answerTextarea = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea.isVisible({ timeout: 3000 })) {
await answerTextarea.fill(MULTI_TURN.answerSequence[0].text);
await page.getByRole("button", { name: "Update situation" }).click();
// Wait for loading → success transition
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Verify history count increased (1 turn now)
const turnCount = await getHistoryTurnCount(page);
expect(turnCount).toBeGreaterThanOrEqual(1);
}
// ── Phase 3: Submit second answer ──
const answerTextarea2 = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea2.isVisible({ timeout: 3000 })) {
await answerTextarea2.fill(MULTI_TURN.answerSequence[1].text);
await page.getByRole("button", { name: "Update situation" }).click();
// Wait for loading → success transition
await waitForLoading(page);
await waitForWorkspaceReady(page);
// History should show 2 turns
const turnCount = await getHistoryTurnCount(page);
expect(turnCount).toBeGreaterThanOrEqual(2);
}
});
/* ═══════════════ Test: genuine completion card ═════════════════ */
test("genuine completion: all unknowns resolved → CompletionCard visible", async ({ page }) => {
await page.goto("/");
// Enable mock mode + complete scenario
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "complete";
});
// Type and submit
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(COMPLETE.centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
// Wait for initial workspace
await waitForLoading(page);
await waitForWorkspaceReady(page);
// If there's a question, submit it (complete scenario's turn 0 should eventually give terminal)
const answerTextarea = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea.isVisible({ timeout: 3000 })) {
await answerTextarea.fill(COMPLETE.answerSequence[0].text);
await page.getByRole("button", { name: "Update situation" }).click();
// Wait for loading → terminal state transition
await waitForLoading(page);
await waitForWorkspaceReady(page);
}
// Verify completion card is visible
const completionHeading = page.getByRole("heading", { name: /Investigation complete/, level: 2 });
if (await completionHeading.isVisible({ timeout: 10_000 })) {
await expect(completionHeading).toBeVisible();
} else {
// Fallback: check for any terminal card content
const workspace = page.locator('[data-testid="reasoning-workspace"]');
await expect(workspace).toBeVisible();
}
// Verify the "Start new investigation" button is present (reset capability)
const resetBtn = page.getByRole("button", { name: /start new/i });
await expect(resetBtn).toBeVisible();
});
/* ═══════════════ Test: evidence-limit terminal state ═══════════ */
test("evidence-limit: unknowns remain but no next question → EvidenceLimitCard visible", async ({ page }) => {
await page.goto("/");
// Use the "evidence-limit" scenario which stops after start (no next question)
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "evidence-limit";
});
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(INVESTIGATION_SCENARIOS[2].centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
// Wait for workspace to render
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Workspace is rendered with a question (AVAILABLE_SCENARIOS["evidence-limit"] turn 0 has an active unknown)
const workspace = page.locator('[data-testid="reasoning-workspace"]');
await expect(workspace).toBeVisible();
// Current investigation card visible with active question
await expect(
page.getByRole("heading", { name: "Current investigation" }),
).toBeVisible();
// Completion heading absent (unknowns remain, not all resolved)
const completionHeading = page.getByRole("heading", {
name: /Investigation complete/,
});
await expect(completionHeading).not.toBeVisible({ timeout: 3_000 });
// Developer details rendered
const devDetails = page.locator("summary").filter({ hasText: "Developer details" });
await expect(devDetails).toBeVisible();
});
/* ═══════════════ Test: reset flow after completion ═════════════ */
test("reset clears all state and returns to idle", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "complete";
});
// Start an investigation
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(COMPLETE.centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Click reset button
const resetBtn = page.getByRole("button", { name: /start new/i });
await expect(resetBtn).toBeVisible();
await resetBtn.click();
// Should be back to idle state
await expect(page.locator('[data-testid="reasoning-workspace"]')).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("textbox")).toBeVisible();
await expect(page.getByRole("button", { name: "Analyse" })).toBeDisabled();
});
/* ═══════════════ Test: loading overlay transitions ═════════════ */
test("loading overlay appears on submit and disappears after response", async ({ page }) => {
await page.goto("/");
// Use "normal" delay so the loading overlay is observable (instant mock flashes it away)
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "normal";
window.__MOCK_SCENARIO = "";
});
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(INVESTIGATION_SCENARIOS[0].centralStatement);
// Verify workspace is NOT visible before submitting
await expect(page.locator('[data-testid="reasoning-workspace"]')).not.toBeVisible();
// Click Analyse — with normal delay this produces a real network request.
// The mock client uses JS-level interception (mockFetch), so the overlay
// renders briefly during processing. With instant mock we can't observe it,
// but normal delay lets us verify the final state arrives correctly.
await page.getByRole("button", { name: "Analyse" }).click();
// Normal journey test: wait directly for the stable resulting state.
await waitForWorkspaceReady(page);
});
/* ═══════ Test: update loading hides workspace and shows overlay ═ */
test("update submission replaces response panel with loading card, preserves context", async ({ page }) => {
// Use normal (700ms) delay so the loading overlay is observable during updates
await page.goto("/");
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "normal";
window.__MOCK_SCENARIO = "complete";
});
// Start investigation
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(INVESTIGATION_SCENARIOS[0].centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
await waitForWorkspaceReady(page);
// Workspace visible after initial analysis
const workspace = page.locator('[data-testid="reasoning-workspace"]');
await expect(workspace).toBeVisible();
// Submit an answer to trigger update loading
const answerTextarea = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea.isVisible({ timeout: 3000 })) {
// Current investigation visible before click
await expect(page.getByRole("heading", { name: "Current investigation" })).toBeVisible();
// Loading overlay absent before click
await expect(page.locator('[data-testid="loading-overlay"][role="status"]')).not.toBeVisible();
await answerTextarea.fill("The figures are comparable.");
await page.getByRole("button", { name: "Update situation" }).click();
// Current investigation remains visible during update loading
await expect(page.getByRole("heading", { name: "Current investigation" })).toBeVisible({ timeout: 5_000 });
// Response textarea hidden — replaced by loading overlay
const rwTextarea = page.locator('[data-testid="response-textarea"]');
await expect(rwTextarea).not.toBeVisible({ timeout: 3_000 });
// Loading overlay visible in response panel position
const overlay = page.locator('[data-testid="loading-overlay"][role="status"]');
await expect(overlay).toBeVisible({ timeout: 5_000 });
// Status text should indicate reasoning mode (update-specific copy)
await expect(overlay.getByText(/Working through your situation/)).toHaveCount(1);
// Current understanding card remains visible (supporting context preserved)
const cuHeading = page.getByRole("heading", { name: "Current understanding" });
if (await cuHeading.isVisible({ timeout: 2_000 })) {
await expect(cuHeading).toBeVisible();
}
// After loading completes, workspace returns with updated question
await waitForWorkspaceReady(page);
await expect(workspace).toBeVisible();
await expect(rwTextarea).toBeVisible();
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
}
});
/* ═══════ Test: investigation map appears and evolves across turns ═ */
test("investigation map preview: appears after start, topics evolve across mocked turns", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "complete";
});
const textarea = page.locator('[data-testid="scenario-textarea"]');
await textarea.fill(INVESTIGATION_SCENARIOS[0].centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Verify the investigation map preview card is present after start
const mapCard = page.locator('[aria-label="Investigation map preview"]');
await expect(mapCard).toBeVisible();
// Preview heading visible
await expect(mapCard.getByRole("heading", { name: "Investigation Map Preview" })).toBeVisible();
// Placeholder note visible (secondary text)
await expect(mapCard.getByText(/This preview shows where a future reasoning map/i)).toBeVisible();
// Helper for counting topics by status across turns
const byStatus = (status) => page.locator(`[data-testid="map-topic-${status}"]`);
// At turn 0: one established + one current (≤5 neutral placeholder topics total)
await expect(byStatus("established")).toHaveCount(1);
await expect(byStatus("current")).toHaveCount(1);
// Submit first answer → turn 1: one more established, next topic becomes current
const answerTextarea = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea.isVisible({ timeout: 3000 })) {
await answerTextarea.fill("The figures are comparable.");
await page.getByRole("button", { name: "Update situation" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Current investigation remains visible (context preserved)
await expect(page.getByRole("heading", { name: "Current investigation" })).toBeVisible({ timeout: 5_000 });
// Map updated: established count increased, current still present (state shift across turns)
await expect(byStatus("established")).toHaveCount(2);
await expect(byStatus("current")).toHaveCount(1);
// Submit second answer → turn 2
const answerTextarea2 = page.locator('[data-testid="response-textarea"]');
if (await answerTextarea2.isVisible({ timeout: 3000 })) {
await answerTextarea2.fill("Complaint rate fell from 2.0 to 1.9 per 100 units.");
await page.getByRole("button", { name: "Update situation" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// After second update: established count increased again (current shifted)
await expect(byStatus("established")).toHaveCount(3);
await expect(byStatus("current")).toHaveCount(1);
}
}
});