4e38b5a791
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>
125 lines
4.5 KiB
TypeScript
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();
|
|
});
|
|
});
|