Files
rootnode-academy/infra/scripts/drills/test-phase6f-root-v1-pilot.js
T
ced4568andClaude Sonnet 5 bd55b74b4f test(pilot): add multi-student and end-to-end ROOT_ V1 rehearsals
- test-phase6f-root-v1-pilot.js (22/22): a real cohort + live classroom session with three
  students in genuinely different states — on track, a real Lab validation failure/help-
  request/fix cycle, and a real admin Lab-reap/recreate with academic-state persistence
  proven, not assumed. Instructor visibility distinguishes all three.
- test-phase6f-root-v1-end-to-end.js (14/14): one student, entirely through the real
  student API, ROOT 01 through Capstone-eligible, including a real nested-Docker
  container-networking fix and an explicit proof that the real rootnode-capstone assessment
  is genuinely startable at the end — not just that the course tree says complete. Found and
  documented (not fixed) a real, pre-existing gap: the legacy six-stage Capstone unlock_rule
  requires two scenario sets (root-07-m01/m02) that are not course-layer prerequisites of
  anything on the path to root-capstone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 18:33:35 -05:00

203 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Phase 6F Step 23 — multi-student ROOT_ V1 pilot rehearsal. Three students in genuinely
// different states inside one real instructor-led cohort/classroom session: Student A stays
// on track, Student B hits a REAL validation failure and needs help before fixing it, Student C
// has their real disposable Lab reaped (admin-destroyed) mid-task and must recreate it. Verifies
// instructor visibility distinguishes all three, no cross-student progress leakage, and no
// academic-state loss from the reap. Real Labs used for Student B and C's own tasks; Student A's
// path uses the same legitimate direct-scenario-completion pattern e2e-phase6e.js established
// for already-proven ROOT 01 content (no bypass of server-side prerequisite enforcement).
//
// Usage: bash infra/scripts/drills/run-phase6f-root-v1-pilot.sh
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
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++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }).toString().trim();
}
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function main() {
console.log('Phase 6F ROOT_ V1 multi-student pilot rehearsal — real cohort, real Labs\n');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const cohortId = `p6f-pilot-${RUN_ID}`;
await step('instructor creates a cohort and a live classroom session', async () => {
const r = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'ROOT_ V1 Pilot Rehearsal', tier: 'root' });
assert.equal(r.status, 201);
});
const students = {};
for (const tag of ['A', 'B', 'C']) {
await step(`create and enroll Student ${tag}`, async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `Student${tag}`, cohort_id: cohortId, login_identifier: `p6f-${tag.toLowerCase()}-${RUN_ID}` });
const session = makeSession(GW);
const act = await session.request('POST', '/api/student/activate', { token: create.activation_token, password: `Student${tag}Pass123!` });
assert.equal(act.status, 200);
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
students[tag] = { session, studentId: create.student_id, enrollmentId: enroll.enrollment_id };
});
}
let classroomId;
await step('instructor starts a live classroom session for the cohort', async () => {
const r = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'ROOT_ V1 Pilot Session' });
assert.equal(r.status, 201);
classroomId = r.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
});
for (const tag of ['A', 'B', 'C']) {
await step(`Student ${tag} joins the live classroom session`, async () => {
const r = await students[tag].session.request('POST', `/api/classroom/${classroomId}/join`);
assert.equal(r.status, 200);
});
}
// ── Student A: on track — real orientation + module 1 progression ──────────────────────
await step('Student A completes the orientation module and starts ROOT 01 module 1 (on track)', async () => {
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await students.A.session.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, id);
}
});
// ── Student B: needs help — a REAL Lab, a REAL failed validation, then a real fix ───────
let bEnvId, bLabToken;
await step('Student B launches a REAL disposable Lab', async () => {
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) await students.B.session.request('POST', `/api/student/activities/${id}/complete`);
const launch = await students.B.session.request('POST', `/api/student/programs/${students.B.enrollmentId}/launch`);
assert.equal(launch.status, 200);
bLabToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const envRow = (await owner.request('GET', `/api/admin/students/${students.B.studentId}/environment`)).body.find((e) => e.status === 'running');
bEnvId = envRow.environment_id;
});
await step('Student B attempts the linux-inspect-hardware Lab and genuinely FAILS validation first (nothing done yet)', async () => {
const setup = await students.B.session.request('POST', `/lab/${bLabToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const validate = await students.B.session.request('POST', `/lab/${bLabToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, false, 'expected a REAL failure — the report file was never written');
});
await step('Student B sends a real Need Help request, categorized lab_problem', async () => {
const r = await students.B.session.request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'My hardware report Lab keeps failing validation.' });
assert.equal(r.status, 201);
});
await step('the instructor sees Student B\'s help request immediately', async () => {
const r = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}/help-requests`);
assert.equal(r.status, 200);
const mine = r.body.find((h) => h.student_id === students.B.studentId);
assert.ok(mine, 'instructor must see the real help request');
assert.equal(mine.category, 'lab_problem');
assert.equal(mine.status, 'open');
});
await step('Student B actually fixes it — real shell commands in the real Lab container — and validation now genuinely passes', async () => {
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${bEnvId}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase6f-hardware-report-${bEnvId}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await students.B.session.request('POST', `/lab/${bLabToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true, 'expected the real fix to genuinely pass now');
});
await step('the instructor acknowledges and resolves Student B\'s help request', async () => {
const list = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}/help-requests`);
const mine = list.body.find((h) => h.student_id === students.B.studentId);
const ack = await owner.request('POST', `/api/admin/help-requests/${mine.help_request_id}/acknowledge`);
assert.equal(ack.status, 200);
const resolve = await owner.request('POST', `/api/admin/help-requests/${mine.help_request_id}/resolve`);
assert.equal(resolve.status, 200);
});
// ── Student C: Lab reaped mid-task, must recreate — academic state must survive ─────────
let cEnvId;
await step('Student C launches a REAL disposable Lab and starts a real Lab task', async () => {
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) await students.C.session.request('POST', `/api/student/activities/${id}/complete`);
const launch = await students.C.session.request('POST', `/api/student/programs/${students.C.enrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await students.C.session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${students.C.studentId}/environment`)).body.find((e) => e.status === 'running');
cEnvId = envRow.environment_id;
});
await step('the instructor reaps Student C\'s Lab mid-task (real admin destroy, simulating an idle-reaper event)', async () => {
const r = await owner.request('DELETE', `/api/admin/environments/${cEnvId}`);
assert.equal(r.status, 200);
});
await step('Student C\'s academic progress survived the reap (orientation still complete)', async () => {
const { body } = await students.C.session.request('GET', `/api/student/programs/${students.C.enrollmentId}/courses`);
assert.equal(moduleById(body.courses, 'root-01-m00-welcome-to-root').complete, true, 'orientation completion must survive a Lab reap');
});
await step('Student C recreates a fresh Lab and can pick the task back up', async () => {
const launch = await students.C.session.request('POST', `/api/student/programs/${students.C.enrollmentId}/launch`);
assert.equal(launch.status, 200);
assert.equal(launch.body.status, 'running');
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await students.C.session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200, 'the recreated Lab must accept the same task setup again');
});
// ── Instructor sees three genuinely different states ────────────────────────────────────
await step('the instructor sees three genuinely different student states in the classroom view', async () => {
const { body } = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const names = body.students.map((s) => s.display_name);
assert.ok(names.includes('StudentA') && names.includes('StudentB') && names.includes('StudentC'));
const helpHistory = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}/help-requests`);
const bRequest = helpHistory.body.find((h) => h.student_id === students.B.studentId);
assert.equal(bRequest.status, 'resolved', 'Student B\'s help request should show resolved, distinguishing them from A and C');
});
await step('cross-student isolation: Student A is genuinely ahead of B and C, and none of B/C\'s events touched A', async () => {
const [{ body: aBody }, { body: bBody }, { body: cBody }] = await Promise.all([
students.A.session.request('GET', `/api/student/programs/${students.A.enrollmentId}/courses`),
students.B.session.request('GET', `/api/student/programs/${students.B.enrollmentId}/courses`),
students.C.session.request('GET', `/api/student/programs/${students.C.enrollmentId}/courses`),
]);
assert.equal(moduleById(aBody.courses, 'root-01-m00-welcome-to-root').complete, true, 'Student A completed orientation');
const aLesson = moduleById(aBody.courses, 'root-01-m01-how-computers-run-software').activities.find((act) => act.activity_id === 'root-01-m01-a01-lesson');
assert.equal(aLesson.complete, true, 'Student A explicitly marked the module 1 lesson complete');
assert.equal(moduleById(bBody.courses, 'root-01-m00-welcome-to-root').complete, true, 'Student B also completed orientation, independently');
const bLesson = moduleById(bBody.courses, 'root-01-m01-how-computers-run-software').activities.find((act) => act.activity_id === 'root-01-m01-a01-lesson');
assert.equal(bLesson.complete, false, 'Student B never marked the lesson complete — only ran the real Lab task, isolated from A\'s manual-marker progress');
assert.equal(moduleById(cBody.courses, 'root-01-m00-welcome-to-root').complete, true, 'Student C also completed orientation, independently, surviving their own Lab reap');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });