Feature/product platform foundation v0.62 #1
@@ -355,6 +355,7 @@ function InvestigationHistoryCard({ turn }) {
|
||||
className="rounded-lg border border-gray-100 bg-gray-50/60"
|
||||
key={turn.id}
|
||||
open={!isCollapsed}
|
||||
data-testid="investigation-turn"
|
||||
>
|
||||
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
{isAnswered && <span aria-hidden="true">✓ </span>}
|
||||
@@ -390,13 +391,13 @@ function InvestigationHistory({ turns }) {
|
||||
const latestId = turns[turns.length - 1].id;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3" data-testid="investigation-history">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
Investigation history
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{turns.map((turn) => (
|
||||
<InvestigationHistoryCard key={turn.id} turn={{ ...turn, _collapsed: turn.id !== latestId }} />
|
||||
<InvestigationHistoryCard key={turn.id} data-testid="investigation-turn" turn={{ ...turn, _collapsed: turn.id !== latestId }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,7 +491,7 @@ function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-blue-50 px-5 py-6" role="status" aria-busy="true">
|
||||
<div className="rounded-lg border border-gray-200 bg-blue-50 px-5 py-6" role="status" aria-busy="true" data-testid="loading-overlay">
|
||||
<div className="flex items-center gap-3">
|
||||
<ActivitySpinner />
|
||||
<span className="text-base font-medium text-blue-900">Working through your situation</span>
|
||||
@@ -626,7 +627,7 @@ export default function ReasoningWorkspace({
|
||||
Boolean(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary) || !hasSelectedQuestion;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6" data-testid="reasoning-workspace">
|
||||
{/* ── Loading overlays ─────────────────────────────── */}
|
||||
{status === "loading" && (
|
||||
<LoadingOverlay
|
||||
|
||||
+30
-2
@@ -1,5 +1,33 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright config for UI mock-mode E2E tests.
|
||||
*
|
||||
* These tests validate layout, state transitions, loading behaviour,
|
||||
* history rendering, and terminal / recovery states.
|
||||
* They do NOT validate reasoning correctness, candidate selection,
|
||||
* decomposition quality, confidence propagation, or question quality.
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
use: { headless: true, screenshot: "only-on-failure", actionTimeout: 120000 },
|
||||
testMatch: "**/tests/smoke.test.js",
|
||||
testDir: "./tests/e2e",
|
||||
outputDir: "test-results",
|
||||
fullyParallel: false,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
webServer: null,
|
||||
use: {
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 30_000,
|
||||
trace: "off",
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
retries: 0,
|
||||
projects: [
|
||||
{
|
||||
name: "mock-mode",
|
||||
testMatch: /.*\.spec\.js/,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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.getByRole("textbox").first()).toHaveAttribute("placeholder", /Describe/);
|
||||
|
||||
// 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("textarea[placeholder*=Describe]");
|
||||
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("textarea[placeholder*=Describe]");
|
||||
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('textarea[placeholder*=Answer]');
|
||||
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('textarea[placeholder*=Answer]');
|
||||
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("textarea[placeholder*=Describe]");
|
||||
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('textarea[placeholder*=Answer]');
|
||||
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("textarea[placeholder*=Describe]");
|
||||
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("textarea[placeholder*=Describe]");
|
||||
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("textarea[placeholder*=Describe]");
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user