- 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]>
203 lines
13 KiB
JavaScript
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); });
|