Files
Curio/e2e/learn-flow.test.ts
T
arnaudne 4e38b5a791 feat: initial commit — Curio mastery tutor
Full Next.js App Router application with:
- AI-graded lesson checkpoints (BKT/Elo mastery tracking)
- Auth.js v5: credentials, Google, GitHub, generic OIDC
- Anonymous-session-first with migrate-on-signin
- Admin panel: users, blueprints, reports, site settings
- Password reset + email verification (nodemailer/SMTP)
- Site config: require_auth + signups_enabled flags
- server-only guards on all DB/generation/verification modules
- PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM
- 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:25:43 +02:00

125 lines
4.5 KiB
TypeScript

/**
* E2E: full learn flow.
*
* Runs against the live dev server with seeded data (pnpm seed).
* Mocks only the grade and socratic API calls (avoid real LLM cost).
* Lesson data comes from the DB (seeded js-closures corpus).
*
* Pre-condition: `docker compose up -d && pnpm seed` must have run.
*/
import { test, expect } from '@playwright/test';
// ── Mock payloads ─────────────────────────────────────────────────────────────
const MOCK_GRADE_MASTERED = {
responseId: 'resp-uuid-001',
gradeId: 'grade-uuid-001',
grade: {
verdict: 'mastered',
rubric_hits: ['r1', 'r2'],
misconception_tag: 'none',
evidence_span: 'retains access to variables from its enclosing scope',
feedback_directive: 'advance',
confidence: 0.92,
},
};
const MOCK_GRADE_PARTIAL = {
responseId: 'resp-uuid-002',
gradeId: 'grade-uuid-002',
grade: {
verdict: 'partial',
rubric_hits: ['r1'],
misconception_tag: 'none',
evidence_span: 'a function inside a function',
feedback_directive: 'probe: Can you say more about what happens after the outer function returns?',
confidence: 0.78,
},
};
const MOCK_GRADE_BUDGET_EXCEEDED = {
status: 429,
json: { error: 'Session limit reached. Start a new session to continue.' },
};
// ── Tests ─────────────────────────────────────────────────────────────────────
test.describe('learn flow (seeded DB)', () => {
test('homepage loads with intent input', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Curio');
await expect(page.locator('[aria-label="Learning intent"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeDisabled();
});
test('intent submission navigates to lesson page', async ({ page }) => {
await page.route('/api/intent', (route) =>
route.fulfill({
json: { intentKey: 'javascript-closures', blueprintId: 'bp-seed', isNew: false },
}),
);
await page.goto('/');
await page.fill('[aria-label="Learning intent"]', 'JavaScript closures');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/learn/javascript-closures');
});
test('lesson page renders seeded content', async ({ page }) => {
await page.goto('/learn/javascript-closures');
// Blueprint title
await expect(page.locator('h1')).toContainText('JavaScript Closures');
// First segment body is visible
await expect(page.getByText(/closure/i).first()).toBeVisible();
// First checkpoint prompt is visible
await expect(page.getByText(/explain what a closure is/i)).toBeVisible();
});
test('mastered verdict unlocks next segment', async ({ page }) => {
await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_MASTERED }));
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('A closure retains access to variables from its enclosing scope even after the outer function returns.');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/mastered/i)).toBeVisible();
// Second segment should now be unlocked (no longer shows "locked" hint)
await expect(page.getByText('Complete the previous checkpoint to continue.')).not.toBeVisible();
});
test('partial verdict shows probe question', async ({ page }) => {
await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_PARTIAL }));
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('a function inside a function');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/partial/i)).toBeVisible();
await expect(
page.getByText(/what happens after the outer function returns/i),
).toBeVisible();
});
test('budget exceeded shows friendly error', async ({ page }) => {
await page.route('/api/grade', (route) =>
route.fulfill(MOCK_GRADE_BUDGET_EXCEEDED),
);
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('some answer');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/session limit/i)).toBeVisible();
});
});