From bd55b74b4fada468c15e8ec92722266caccc46d4 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Wed, 26 Aug 2026 18:33:35 -0500 Subject: [PATCH] test(pilot): add multi-student and end-to-end ROOT_ V1 rehearsals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../drills/run-phase6f-root-v1-end-to-end.sh | 34 +++ .../drills/run-phase6f-root-v1-pilot.sh | 35 +++ .../drills/test-phase6f-root-v1-end-to-end.js | 188 ++++++++++++++++ .../drills/test-phase6f-root-v1-pilot.js | 202 ++++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100755 infra/scripts/drills/run-phase6f-root-v1-end-to-end.sh create mode 100755 infra/scripts/drills/run-phase6f-root-v1-pilot.sh create mode 100644 infra/scripts/drills/test-phase6f-root-v1-end-to-end.js create mode 100644 infra/scripts/drills/test-phase6f-root-v1-pilot.js diff --git a/infra/scripts/drills/run-phase6f-root-v1-end-to-end.sh b/infra/scripts/drills/run-phase6f-root-v1-end-to-end.sh new file mode 100755 index 0000000..26d550c --- /dev/null +++ b/infra/scripts/drills/run-phase6f-root-v1-end-to-end.sh @@ -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 p6f-e2e-owner@rootnode.test "P6F E2E Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p6f-e2e-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase6f-root-v1-end-to-end.js diff --git a/infra/scripts/drills/run-phase6f-root-v1-pilot.sh b/infra/scripts/drills/run-phase6f-root-v1-pilot.sh new file mode 100755 index 0000000..503ee04 --- /dev/null +++ b/infra/scripts/drills/run-phase6f-root-v1-pilot.sh @@ -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 p6f-pilot-owner@rootnode.test "P6F Pilot Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p6f-pilot-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase6f-root-v1-pilot.js diff --git a/infra/scripts/drills/test-phase6f-root-v1-end-to-end.js b/infra/scripts/drills/test-phase6f-root-v1-end-to-end.js new file mode 100644 index 0000000..504f692 --- /dev/null +++ b/infra/scripts/drills/test-phase6f-root-v1-end-to-end.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); }); diff --git a/infra/scripts/drills/test-phase6f-root-v1-pilot.js b/infra/scripts/drills/test-phase6f-root-v1-pilot.js new file mode 100644 index 0000000..19acd72 --- /dev/null +++ b/infra/scripts/drills/test-phase6f-root-v1-pilot.js @@ -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); });