#!/usr/bin/env node // Phase 4.1 §27 — the fake cohort dress rehearsal. One continuous narrative through the real // application (not synthetic unit assertions): instructor login, cohort + 4 students, assign // curriculum, Prepare Labs, students launch, work different scenarios, use hints, fail a // validation, complete scenarios, instructor checks analytics, an exam is assigned/taken, // a Lab is reset, a Lab is reaped (destroyed out-of-band, the same consequence the real idle // reaper produces — see the note below on why this doesn't wait out the real multi-minute // reaper timer), the student returns and continues, capstone state is checked, and the cohort's // Labs are destroyed at the end. // // Run via run-fake-cohort-rehearsal.sh, which brings up platform/docker-compose.dev.yml first. // No framework, matching project convention. Problems found are printed, not silently passed — // see the Phase 4.1 report for anything this surfaced. const assert = require('assert'); const { makeSession } = require('../../../platform/test/session-helper'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const OWNER_EMAIL = process.env.OWNER_EMAIL; const OWNER_PASSWORD = process.env.OWNER_PASSWORD; if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); } let passed = 0, failed = 0; async function step(name, fn) { try { await fn(); console.log(` ok — ${name}`); passed++; } catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; } } async function main() { console.log('Phase 4.1 fake cohort dress rehearsal — slow, real k3s boots\n'); const instructor = makeSession(GW); await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); const names = ['Rehearsal-Ada', 'Rehearsal-Bo', 'Rehearsal-Cy', 'Rehearsal-Di']; const students = {}; const sessions = {}; await step('instructor: create a cohort and roster 4 students', async () => { await instructor.request('POST', '/api/admin/cohorts', { cohort_id: 'rehearsal-cohort', name: 'Rehearsal Cohort' }); for (const name of names) { const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: 'rehearsal-cohort' }); students[name] = body.student_id; sessions[name] = makeSession(GW); await sessions[name].request('POST', '/api/login', { student_id: body.student_id }); } }); await step('instructor: assign curriculum to the cohort', async () => { const { status } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/curriculum', { curriculum_path: 'rootnode-path' }); assert.ok([200, 201].includes(status)); }); await step('instructor: Prepare Labs for the whole cohort (concurrent)', async () => { const { status, body } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/prepare-labs'); assert.equal(status, 200); assert.ok(body.results.every((r) => r.status === 'running'), `expected every Lab running, got: ${JSON.stringify(body.results)}`); }); const launches = {}; await step('students: everyone\'s prepared Lab is already running (no re-launch wait)', async () => { for (const name of names) { const { body } = await sessions[name].request('POST', '/api/environment/launch'); assert.equal(body.status, 'running'); launches[name] = body; } }); await step('students: Ada and Bo work DIFFERENT scenarios', async () => { await sessions['Rehearsal-Ada'].request('POST', `${launches['Rehearsal-Ada'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' }); await sessions['Rehearsal-Bo'].request('POST', `${launches['Rehearsal-Bo'].launch_path}api/scenarios/pod-basics-mcq/start`); }); await step('student: Cy uses a hint on a scenario', async () => { const res = await sessions['Rehearsal-Cy'].request('POST', `${launches['Rehearsal-Cy'].launch_path}api/scenarios/pod-basics-mcq/hint`); assert.equal(res.status, 200); }); await step('student: Di fails a validation on purpose', async () => { const res = await sessions['Rehearsal-Di'].request('POST', `${launches['Rehearsal-Di'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'a' }); assert.equal(res.status, 200); assert.equal(res.body.correct, false, 'expected a deliberately wrong answer to be marked incorrect'); }); await step('instructor: cohort learning summary reflects real activity', async () => { const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/learning'); assert.equal(body.student_count, 4); }); await step('instructor: needs-attention returns real signals for the struggling students, no punitive language', async () => { const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/needs-attention'); const text = JSON.stringify(body).toLowerCase(); assert.ok(!text.includes('fail') || text.includes('failed validations'), 'the word "fail" should only appear in a factual "N failed validations" context, never as a standalone judgment'); }); await step('instructor: reset Ada\'s Lab — academic progress is untouched', async () => { const before = await sessions['Rehearsal-Ada'].request('GET', '/api/progress'); // Admin environment routes need the real environment_id, which publicEnvView doesn't echo // back to the student directly — resolved via the student environment lookup instead. const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Ada']}/environment`); const env = envRes.body.find((e) => e.status !== 'destroyed'); await instructor.request('POST', `/api/admin/environments/${env.environment_id}/reset`); const after = await sessions['Rehearsal-Ada'].request('GET', '/api/progress'); assert.deepEqual(before.body.details, after.body.details, 'progress must be identical before/after a Lab reset'); }); await step('§27 — a Lab is reaped: same consequence as the real idle reaper (real multi-minute timer not waited out here — see reaper.js/resource-policy.yaml for the real thresholds; this exercises the destroy-and-preserve-progress consequence directly, already proven mechanistically by infra/scripts/drills/test-lab-host-loss.js)', async () => { const before = await sessions['Rehearsal-Bo'].request('GET', '/api/progress'); const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Bo']}/environment`); const env = envRes.body.find((e) => e.status !== 'destroyed'); await instructor.request('DELETE', `/api/admin/environments/${env.environment_id}`); const after = await sessions['Rehearsal-Bo'].request('GET', '/api/progress'); assert.deepEqual(before.body.details, after.body.details, 'progress must survive a reaped Lab unchanged'); }); await step('student: Bo returns later — a fresh Lab is created and curriculum continues', async () => { const { status, body } = await sessions['Rehearsal-Bo'].request('POST', '/api/environment/launch'); assert.equal(status, 200); assert.equal(body.status, 'running'); const curriculum = await sessions['Rehearsal-Bo'].request('GET', '/api/curriculum/path'); assert.ok(curriculum.body.stages.length > 0, 'curriculum must still be there for the returning student'); }); await step('instructor: assign an exam to the cohort and Ada takes it', async () => { const create = await instructor.request('POST', '/api/admin/assessments', { id: 'rehearsal-exam', type: 'exam', title: 'Rehearsal Exam', duration_minutes: 30, hint_policy: 'disabled', pool_spec: { required: ['pod-basics-mcq'], random: [] }, }); assert.ok([200, 201, 409].includes(create.status)); // 409 if a prior run already created it — fine, idempotent enough for a rehearsal const assign = await instructor.request('POST', '/api/admin/assessments/rehearsal-exam/assign', { cohort_id: 'rehearsal-cohort' }); assert.ok([200, 201].includes(assign.status)); const start = await sessions['Rehearsal-Ada'].request('POST', '/api/assessments/rehearsal-exam/start'); assert.equal(start.status, 201); assert.ok(start.body.scenarios.length > 0); }); await step('instructor: destroy all cohort Labs after class — progress remains intact for everyone', async () => { const before = {}; for (const name of names) before[name] = (await sessions[name].request('GET', '/api/progress')).body.details; await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/destroy-labs'); for (const name of names) { const after = (await sessions[name].request('GET', '/api/progress')).body.details; assert.deepEqual(before[name], after, `${name}'s progress must survive end-of-class Lab destruction`); } }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });