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]>
This commit is contained in:
2026-08-26 18:33:35 -05:00
co-authored by Claude Sonnet 5
parent 2864fe03dc
commit bd55b74b4f
4 changed files with 459 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Orchestrates the Phase 6F Step 24 ROOT_ V1 end-to-end acceptance rehearsal: brings up the real
# dev stack, bootstraps an owner, runs test-phase6f-root-v1-end-to-end.js (one student, real
# student API throughout, ROOT 01 through genuine Capstone-assessment-startable), tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P6F E2E Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-phase6f-root-v1-end-to-end.js
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Orchestrates the Phase 6F Step 23 ROOT_ V1 multi-student pilot rehearsal: brings up the real
# dev stack, bootstraps an owner, runs test-phase6f-root-v1-pilot.js (real cohort, real
# classroom session, real disposable Labs, a real validation failure/fix, a real Lab reap), tears
# down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P6F Pilot Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-phase6f-root-v1-pilot.js
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Phase 6F Step 24 — the broadest curriculum acceptance test yet: proves one real student can
// progress ROOT 01 -> 02 -> 03 -> 04 -> 05+ -> Security -> Troubleshooting -> Real Ops ->
// Capstone-eligible, entirely through the REAL student API (never an admin shortcut, never a
// direct database write to fake completion), with every real server-side prerequisite lock
// actually enforced along the way — not bypassed. Real disposable Lab + real nested Docker used
// for one genuine checkpoint (the ROOT 04 container-networking diagnose-and-fix, matching the
// standard Phase 6D/6E rehearsals already established); the rest of ROOT 01-04's real-Lab
// mechanics were already proven end-to-end in those rehearsals and in this phase's own
// multi-student pilot rehearsal, so this script does not repeat them — it proves the ADDITIONAL
// distance (05 through Capstone-eligibility) that no prior phase's rehearsal covered.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const { completeRootFoundations01Through04 } = require('../../../platform/test/foundations-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 courseById(courses, id) { return courses.find((c) => c.course_id === id); }
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 act(session, id) { const r = await session.request('POST', `/api/student/activities/${id}/complete`); assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`); }
async function scen(session, sid) { const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); assert.equal(r.status, 200, `completing scenario ${sid}`); }
async function main() {
console.log('Phase 6F ROOT_ V1 end-to-end acceptance rehearsal — ROOT 01 through Capstone eligibility\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 { body: create } = await owner.request('POST', '/api/admin/students', { display_name: 'E2E V1 Journey', login_identifier: `p6f-journey-${RUN_ID}` });
const session = makeSession(GW);
let enrollmentId;
await step('student activates and is enrolled in ROOT_', async () => {
const act1 = await session.request('POST', '/api/student/activate', { token: create.activation_token, password: 'JourneyPass123!' });
assert.equal(act1.status, 200);
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
enrollmentId = enroll.enrollment_id;
});
await step('ROOT 01-04 (Linux/Git/Docker foundations) complete through the real student API, unlocking ROOT 05', async () => {
await completeRootFoundations01Through04(session);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-04-containers-and-docker').complete, true);
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false);
});
// ── One real Lab checkpoint: the ROOT 04 diagnose-and-fix, re-proven live on THIS student ──
await step('practical boundary check: a real nested-Docker container-networking fix, run live on this student\'s own Lab', async () => {
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/docker-container-networking/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${create.student_id}/environment`)).body.find((e) => e.status === 'running');
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 = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
// Confirm the REAL pre-fix failure before fixing it.
let failedBefore = false;
try { execSync(`docker exec ${containerName} curl -sf --max-time 3 http://localhost:8099`, { stdio: 'pipe' }); }
catch (e) { failedBefore = true; }
assert.equal(failedBefore, true, 'expected a REAL connection failure before the fix (container started without -p)');
execSync(`docker exec ${containerName} docker rm -f net-lab-web`, { stdio: 'pipe' });
execSync(`docker exec ${containerName} docker run -d --name net-lab-web -p 8099:80 nginx:alpine`, { stdio: 'pipe' });
const afterFix = execSync(`docker exec ${containerName} curl -sf --max-time 5 http://localhost:8099`, { stdio: 'pipe' }).toString();
assert.ok(afterFix.includes('nginx') || afterFix.length > 0, 'expected a real successful response after the real fix');
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/docker-container-networking/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true);
await scen(session, 'docker-container-networking');
});
await step('direct API cannot skip ahead: ROOT 09/10/12/Capstone all remain locked until ROOT 05/06 are genuinely complete', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
for (const id of ['root-09-kubernetes-security', 'root-10-kubernetes-troubleshooting-observability', 'root-12-real-ops', 'root-capstone']) {
assert.equal(courseById(body.courses, id).locked, true, `${id} should still be locked`);
}
const bypass = await session.request('POST', '/api/student/activities/root-09-m01-a01-lesson-security-model/complete');
assert.equal(bypass.status, 403);
assert.equal(bypass.body.error, 'locked');
});
await step('ROOT 05 (Kubernetes Foundations) completes and unlocks ROOT 06', async () => {
await act(session, 'root-05-m00-a01-lesson-mental-model'); await act(session, 'root-05-m00-a02-demonstration-desired-state'); await act(session, 'root-05-m00-a03-knowledge-check'); await act(session, 'root-05-m00-a04-guided-exercise-namespaces');
await scen(session, 'namespaces-basics'); await act(session, 'root-05-m00-a06-reflection');
const pods = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics', 'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
for (const sid of pods) await scen(session, sid);
await act(session, 'root-05-m01-a00-lesson-kubectl-fluency'); await act(session, 'root-05-m01-a21-reflection');
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true);
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false);
});
await step('ROOT 06 (Application Workloads, incl. the practice bank) completes and unlocks ROOT 07/09/10', async () => {
for (const sid of ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback']) await scen(session, sid);
await act(session, 'root-06-m02-a01-lesson-deployments'); await act(session, 'root-06-m02-a02-demonstration-rollout'); await act(session, 'root-06-m02-a03-knowledge-check'); await act(session, 'root-06-m02-a04-guided-exercise-rollout-strategy'); await act(session, 'root-06-m02-a06-reflection');
await act(session, 'root-06-m01-a00-lab-bank-intro');
for (const sid of ['job-task', 'cronjob-task']) await scen(session, sid);
await act(session, 'root-06-m03-a01-lesson-jobs-cronjobs-daemonsets'); await act(session, 'root-06-m03-a01b-demonstration-job-lifecycle'); await act(session, 'root-06-m03-a02-knowledge-check'); await act(session, 'root-06-m03-a02b-guided-exercise-choosing-primitive'); await act(session, 'root-06-m03-a05-reflection');
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').complete, true);
assert.equal(courseById(body.courses, 'root-07-kubernetes-networking-config').locked, false);
assert.equal(courseById(body.courses, 'root-09-kubernetes-security').locked, false);
assert.equal(courseById(body.courses, 'root-10-kubernetes-troubleshooting-observability').locked, false);
});
await step('ROOT 09 (Security) completes via a real formal assessment', async () => {
for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan', 'a09-reflection']) await act(session, `root-09-m01-${suffix}`);
for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await scen(session, sid);
for (const suffix of ['a01-lesson-serviceaccounts-rbac', 'a01b-demonstration-rbac-denial', 'a02-knowledge-check', 'a02b-guided-exercise-least-privilege-role', 'a09-reflection']) await act(session, `root-09-m02-${suffix}`);
for (const sid of ['serviceaccount-pod', 'cks-automount-token', 'rbac-role', 'cks-rbac-clusterrole', 'cks-rbac-least-privilege']) await scen(session, sid);
for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) await act(session, `root-07-m03-${suffix}`);
await scen(session, 'expose-service-basics');
for (const suffix of ['a01-lesson-ingress-networkpolicy', 'a01b-demonstration-ingress-routing', 'a02-knowledge-check', 'a02b-guided-exercise-network-isolation', 'a05-reflection']) await act(session, `root-07-m04-${suffix}`);
await scen(session, 'ingress-task'); await scen(session, 'network-policy');
for (const suffix of ['a01-lesson-network-supplychain', 'a01b-demonstration-default-deny', 'a02-knowledge-check', 'a02b-guided-exercise-egress-plan', 'a09-reflection']) await act(session, `root-09-m03-${suffix}`);
for (const sid of ['cks-network-policy', 'cks-egress-namespace', 'cks-tls-ingress', 'cks-image-pull-secret']) await scen(session, sid);
await act(session, 'root-09-m04-a01-lesson-cluster-hardening');
for (const sid of ['cks-psa-namespace', 'cks-immutable-secret']) await scen(session, sid);
const r = await session.request('POST', '/api/assessments/rootnode-security-assessment/start');
assert.equal(r.status, 201);
for (const s of r.body.scenarios) await scen(session, s.scenario_id);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-09-kubernetes-security').complete, true);
});
await step('ROOT 10 (Troubleshooting, incl. the practice bank) completes using the RootNode framework', async () => {
await act(session, 'root-10-m01-a01-lesson-health-check-concepts'); await act(session, 'root-10-m01-a02-demonstration-probes-in-action'); await act(session, 'root-10-m01-a03-knowledge-check'); await act(session, 'root-10-m01-a04-guided-exercise-readiness-probe');
await scen(session, 'rootnode-probe-port-mismatch'); await act(session, 'root-10-m01-a06-reflection-real-ops-takeaway');
await act(session, 'root-10-m03-a01-lesson-framework'); await act(session, 'root-10-m03-a02-demonstration-crashloop'); await act(session, 'root-10-m03-a03-knowledge-check'); await act(session, 'root-10-m03-a03b-guided-exercise-endpoint-mismatch');
await scen(session, 'crashloop-fix'); await scen(session, 'endpoint-fix-task'); await act(session, 'root-10-m03-a06-reflection');
await act(session, 'root-10-m02-a00-lab-bank-intro');
for (const sid of ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq', 'container-logging-mcq']) await scen(session, sid);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-10-kubernetes-troubleshooting-observability').complete, true);
assert.equal(courseById(body.courses, 'root-12-real-ops').locked, false);
});
await step('ROOT 12 (Real Ops) completes, real incident scenarios, deliberately un-guided', async () => {
await act(session, 'root-12-m01-a00-lesson-incident-response');
for (const sid of ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-enforce-limits']) await scen(session, sid);
await act(session, 'root-12-m01-a99-reflection-post-incident');
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true);
});
await step('root-capstone is now genuinely unlocked — both real prerequisites (ROOT 09 AND ROOT 12) satisfied', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-capstone').locked, false);
});
await step('the Capstone Readiness orientation module is completable', async () => {
for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) await act(session, `root-capstone-m02-${suffix}`);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(moduleById(body.courses, 'root-capstone-m02-capstone-readiness').complete, true);
});
await step('the pre-existing legacy six-stage gate (a SEPARATE, older mechanism) also needs its own "networking"/"configuration" stage scenarios — never required by this journey\'s course-layer path, since root-07-m01/m02 aren\'t prerequisites of root-09-m03', async () => {
// "networking" stage needs services-mcq (root-07-m01's scenario; expose-service-basics was
// already done above via root-07-m03). "configuration" stage needs all 5 of root-07-m02's
// scenarios. Neither module is a course-content-model prerequisite of anything this journey
// already needed to touch — proving these two, genuinely separate gates aren't the same
// thing is exactly this step's point.
for (const sid of ['services-mcq', 'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline']) await scen(session, sid);
});
await step('the real Capstone assessment itself is now startable (the pre-existing six-stage gate is also satisfied by this journey)', async () => {
const r = await session.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(r.status, 201, `expected the Capstone to be genuinely startable: ${JSON.stringify(r.body)}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,202 @@
#!/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); });