- New platform/test/e2e-phase6f.js (17 tests): V1 manifest/exclusion correctness, the full V1 prerequisite graph, ROOT 01-04 re-verification, the orientation module's unlock behavior, optional-activity handling, the strengthened Capstone gate in both directions, instructor-notes RBAC on locked (not just draft) content, isolation, and an explicit age-independence check. - foundations-helper.js: completeThroughRoot06/completeThroughRoot10 updated for the new practice-bank intro-lesson activities; new completeThroughCapstoneEligible() helper (ROOT 07-m03/m04 + full ROOT 09 + ROOT 12) shared by e2e-phase6f.js and both new drills. - e2e-phase6a/6b/6d/6e.js: resume-pointer assertions updated for the new orientation module; e2e-phase6a.js's and e2e-phase6b.js's own inline ROOT 06/07/09/10/12 completion sequences updated for the new intro-lesson activities and the new root-09 Capstone dependency; e2e-phase6b.js's stale ROOT 09 status assertion and its draft-activity test (previously targeting the now fully-excluded ROOT 08) corrected. - test-phase6d/6e-foundations-rehearsal.js: same orientation-module fix applied to the real-Lab dress rehearsals. All of 6A (23/23) / 6B (26/26) / 6C (17/17) / 6D (21/21) / 6E (23/23) / 6F (17/17) verified green on independent fresh stacks. See docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. Co-Authored-By: Claude Sonnet 5 <[email protected]>
217 lines
13 KiB
JavaScript
217 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 6D §27 — the ROOT Foundations dress rehearsal. One continuous realistic student
|
|
// narrative through ROOT 01 (Computing & Linux Foundations, fully authored this phase) using a
|
|
// REAL disposable student Lab end to end: login -> program -> course -> lesson -> guided
|
|
// exercise -> real shell interaction -> validated filesystem/process/permission activities ->
|
|
// progress persistence -> assessment -> ROOT 02 unlock state, then an instructor narrative
|
|
// confirming the resulting learning state is visible. ROOT 02-04 are not rehearsed here since
|
|
// they remain unauthored this phase (see docs/PHASE6D_ROOT_FOUNDATIONS.md).
|
|
//
|
|
// Unlike the ROOT 05+ rehearsals, this one never touches kubectl or asserts anything about
|
|
// Kubernetes — ROOT 01 is Linux/shell content, and the assertions below only ever exercise
|
|
// real bash/coreutils commands. (The Lab container's own boot sequence still starts k3s before
|
|
// signaling ready, same as every other course's Lab — that's an existing image-level fact this
|
|
// phase didn't change, not something ROOT 01's content depends on.)
|
|
//
|
|
// Run via run-phase6d-foundations-rehearsal.sh, which brings up platform/docker-compose.dev.yml
|
|
// first.
|
|
|
|
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 OWNER_EMAIL = process.env.OWNER_EMAIL;
|
|
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
|
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
|
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 moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
|
function courseFrom(tree, id) { return tree.find((c) => c.course_id === id); }
|
|
|
|
async function main() {
|
|
console.log('Phase 6D ROOT Foundations dress rehearsal — real disposable Lab, no mocking\n');
|
|
|
|
const owner = makeSession(GW);
|
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
|
|
const { body: created } = await owner.request('POST', '/api/admin/students', {
|
|
display_name: 'Rehearsal 6D Student', login_identifier: `p6d-foundations-${RUN_ID}`,
|
|
});
|
|
const session = makeSession(GW);
|
|
let enrollmentId, envId;
|
|
|
|
await step('student activates credentials and logs in', async () => {
|
|
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
|
|
assert.equal(status, 200);
|
|
});
|
|
|
|
await step('an instructor enrolls the student in the ROOT_ program', async () => {
|
|
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' });
|
|
assert.equal(status, 201);
|
|
enrollmentId = body.enrollment_id;
|
|
});
|
|
|
|
await step('resume points at ROOT 01\'s very first activity', async () => {
|
|
// Phase 6F added a required orientation module in front of root-01-m01 — see
|
|
// docs/PHASE6F_ROOT_V1_PILOT_READINESS.md.
|
|
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations');
|
|
assert.equal(body.resume.activity_id, 'root-01-m00-a01-lesson-welcome');
|
|
});
|
|
|
|
await step('student completes the ROOT_ V1 orientation module', async () => {
|
|
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) {
|
|
const { status } = await session.request('POST', `/api/student/activities/${id}/complete`);
|
|
assert.equal(status, 200);
|
|
}
|
|
});
|
|
|
|
await step('student reads ROOT 01 module 1\'s lesson and guided exercise', async () => {
|
|
for (const suffix of ['a01-lesson', 'a02-guided-exercise']) {
|
|
const { status } = await session.request('POST', `/api/student/activities/root-01-m01-${suffix}/complete`);
|
|
assert.equal(status, 200);
|
|
}
|
|
});
|
|
|
|
await step('student launches a REAL disposable Lab', async () => {
|
|
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
|
assert.equal(launch.status, 200);
|
|
assert.equal(launch.body.status, 'running');
|
|
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
|
|
envId = envRow.environment_id;
|
|
});
|
|
|
|
let labToken;
|
|
await step('student runs the linux-inspect-hardware Lab using REAL shell commands inside the real Lab container', async () => {
|
|
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const mod = moduleFrom(body.courses, 'root-01-m01-how-computers-run-software');
|
|
const lab = mod.activities.find((a) => a.scenario_id === 'linux-inspect-hardware');
|
|
assert.equal(lab.complete, false);
|
|
|
|
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
|
labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
|
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
|
assert.equal(setup.status, 200);
|
|
|
|
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 = '${envId}'"`,
|
|
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
|
).toString().trim();
|
|
|
|
// Real bash, run inside the student's actual disposable Lab container — exactly the
|
|
// commands the module's lesson/hints teach, not a simulated pass. Written to a script FILE
|
|
// on the host and copied in, rather than an inline heredoc, to sidestep JS-template/shell/
|
|
// heredoc triple-escaping entirely — a real, reproducible failure hit on the first run of
|
|
// this rehearsal (see the fix noted in docs/PHASE6D_ROOT_FOUNDATIONS.md).
|
|
const fs = require('fs');
|
|
const scriptPath = `/tmp/phase6d-hardware-report-${envId}.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 session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
|
assert.equal(validate.status, 200);
|
|
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
|
|
|
|
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envId });
|
|
assert.equal(record.status, 200);
|
|
});
|
|
|
|
await step('student runs the linux-permissions-foundations Lab, including a real permission denial and fix', async () => {
|
|
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-permissions-foundations/setup`);
|
|
assert.equal(setup.status, 200);
|
|
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 = '${envId}'"`,
|
|
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
|
).toString().trim();
|
|
|
|
// Confirm the real denial exists before fixing it — this is the actual pedagogical point.
|
|
let denied = false;
|
|
try { execSync(`docker exec ${containerName} su - labuser -c "cat /srv/perm-lab/data.txt"`, { stdio: 'pipe' }); }
|
|
catch (e) { denied = /Permission denied/.test(e.stderr ? e.stderr.toString() : ''); }
|
|
assert.equal(denied, true, 'expected a REAL permission denial before the fix, not an assumption');
|
|
|
|
execSync(`docker exec ${containerName} chmod 644 /srv/perm-lab/data.txt`, { stdio: 'pipe' });
|
|
const afterFix = execSync(`docker exec ${containerName} su - labuser -c "cat /srv/perm-lab/data.txt"`, { stdio: 'pipe' }).toString().trim();
|
|
assert.equal(afterFix, 'sensitive', 'labuser must actually be able to read the file after the real chmod fix');
|
|
|
|
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-permissions-foundations/validate`);
|
|
assert.equal(validate.status, 200);
|
|
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
|
|
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'linux-permissions-foundations', status: 'completed', environment_id: envId });
|
|
assert.equal(record.status, 200);
|
|
});
|
|
|
|
await step('completing the remaining ROOT 01 activities finishes the course and unlocks ROOT 02', async () => {
|
|
await session.request('POST', '/api/student/activities/root-01-m01-a04-reflection/complete');
|
|
// ROOT 01's own modules carry real intra-course prerequisites (m02 requires m01 complete,
|
|
// etc., found and enforced this phase — see docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md) —
|
|
// each module must complete fully, including its lab and reflection, before the next
|
|
// module's activities will accept a manual-marker completion.
|
|
const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' };
|
|
for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) {
|
|
for (const suffix of ['a01-lesson', 'a02-demonstration', 'a03-knowledge-check', 'a04-guided-exercise']) {
|
|
const r = await session.request('POST', `/api/student/activities/root-01-${m}-${suffix}/complete`);
|
|
assert.equal(r.status, 200, `completing root-01-${m}-${suffix} should 200: ${JSON.stringify(r.body)}`);
|
|
}
|
|
await session.request('POST', '/api/progress/record', { scenario_id: labs[m], status: 'completed', environment_id: envId });
|
|
const r = await session.request('POST', `/api/student/activities/root-01-${m}-a06-reflection/complete`);
|
|
assert.equal(r.status, 200, `completing root-01-${m}-a06-reflection should 200: ${JSON.stringify(r.body)}`);
|
|
}
|
|
|
|
const start = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start');
|
|
assert.equal(start.status, 201);
|
|
for (const s of start.body.scenarios) {
|
|
const r = await session.request('POST', '/api/progress/record', { scenario_id: s.scenario_id, status: 'completed', environment_id: envId });
|
|
assert.equal(r.status, 200);
|
|
}
|
|
|
|
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
assert.equal(courseFrom(body.courses, 'root-01-computing-linux-foundations').complete, true);
|
|
assert.equal(courseFrom(body.courses, 'root-02-linux-administration-networking').locked, false, 'ROOT 02 must unlock now (though it has no content of its own yet)');
|
|
});
|
|
|
|
await step('logout, login — ROOT 01 progress survives all of it', async () => {
|
|
await session.request('POST', '/api/logout');
|
|
const returning = makeSession(GW);
|
|
await returning.request('POST', '/api/student/login', { identifier: `p6d-foundations-${RUN_ID}`, password: 'RehearsalPass123!' });
|
|
const { body } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
assert.equal(courseFrom(body.courses, 'root-01-computing-linux-foundations').complete, true, 'progress must survive a real logout/login');
|
|
});
|
|
|
|
// ── Instructor narrative — confirming the resulting learning state is visible ───────────
|
|
await step('instructor inspects the ROOT 01 module structure and its instructor notes', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
assert.equal(status, 200);
|
|
const mod = moduleFrom(body, 'root-01-m06-users-and-permissions-foundations');
|
|
assert.equal(mod.activities.length, 7);
|
|
assert.ok(mod.activities[0].instructor_notes, 'instructor must see instructor notes the student view never does');
|
|
});
|
|
|
|
await step('instructor sees this specific student\'s real, completed ROOT 01 progress', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 200);
|
|
assert.equal(courseFrom(body, 'root-01-computing-linux-foundations').complete, true);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|