const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); require('dotenv').config({ quiet: true }); const { PrismaClient } = require('@prisma/client'); const url = new URL(process.env.DATABASE_URL); url.hostname = '192.168.0.20'; url.port = '15437'; url.pathname = '/lms'; url.searchParams.set('connect_timeout', '5'); const db = new PrismaClient({ datasources: { db: { url: url.toString() } } }); const apply = process.argv.includes('--apply'); const releasePath = process.env.CAMERA_RELEASE_MANIFEST || '/home/chyoo/다운로드/yahboom-lms-modules/exports/jetson-camera/release.json'; const ids = ['4eb01197-7605-4c3b-9a92-f9a3942050bd', '8e296f81-8445-455c-b1a2-47f2ce9099a4', '26d46589-31a5-4d94-b0e0-385a12c41721']; const folderName = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws'; const image = 'future-nav2-camera-test:humble-jetson_orin'; const templateRepositoryUrl = 'https://gitea.autonomystudio.net/templates/lms-nav2-camera-test.git'; const defaults = ['video_device:=/dev/video0', 'width:=640', 'height:=480', 'fps:=30', 'publish_fps:=10', 'pixel_format:=auto']; const snapshot = client => client.course.findMany({ where: { id: { in: ids } }, orderBy: { id: 'asc' }, include: { weeks: { orderBy: { week: 'asc' }, include: { modules: { orderBy: { sortOrder: 'asc' } } } }, environmentProfiles: { orderBy: { id: 'asc' } }, } }); async function main() { const release = JSON.parse(fs.readFileSync(releasePath, 'utf8')); assert.equal(release.runtimeImages.length, 1, 'Runtime image upload must finish first'); assert.equal(release.prebuiltArtifacts.length, 1); assert.equal(release.runtimeImages[0].image, image); assert.equal(release.prebuiltArtifacts[0].architecture, 'arm64'); for (const item of [...release.runtimeImages, ...release.prebuiltArtifacts]) { assert.match(item.checksumSha256, /^[a-f0-9]{64}$/); const check = await fetch(item.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) }); assert(check.ok, `Artifact unavailable: ${check.status}`); } const before = await snapshot(db); assert.equal(before.length, 3, 'Expected the base curriculum and two existing courses'); assert(before.some(c => c.slug === 'futuredrive-autonomy-basic')); for (const c of before) { assert.notEqual(c.title, 'NAV2 알아보기'); const week = c.weeks.find(w => w.week === 3); assert(week && week.modules.some(m => m.name === 'NAV2-MKMini'), `${c.title}: MKMini week 3 missing`); const profiles = c.environmentProfiles.filter(p => p.week === 3); assert.equal(profiles.filter(p => p.config.target === 'remote-jetson').length, 1); assert(profiles.filter(p => p.config.target === 'local').every(p => p.config.moduleSet?.includeUnlisted === false), 'Local profile would implicitly include the ARM64-only camera'); } if (apply) { fs.mkdirSync('backups', { recursive: true }); const backup = path.resolve('backups', `week3-before-camera-${Date.now()}.json`); fs.writeFileSync(backup, JSON.stringify(before, null, 2), { flag: 'wx', mode: 0o600 }); console.log('Backup:', backup); } const summary = []; try { await db.$transaction(async tx => { assert.deepEqual(await snapshot(tx), before, 'Curriculum changed during preparation'); for (const c of before) { const week = c.weeks.find(w => w.week === 3); const existing = week.modules.find(m => m.folderName === folderName); const data = { name: 'NAV2-Camera', category: 'Camera', folderName, templateRepositoryUrl, sourceType: 'GIT', dockerImage: image, defaultParameters: defaults, prebuiltArtifacts: release.prebuiltArtifacts }; const module = existing ? await tx.courseWeekModule.update({ where: { id: existing.id }, data }) : await tx.courseWeekModule.create({ data: { ...data, weekId: week.id, sortOrder: Math.max(0, ...week.modules.map(m => m.sortOrder)) + 1 } }); const profile = c.environmentProfiles.find(p => p.week === 3 && p.config.target === 'remote-jetson'); const config = structuredClone(profile.config); assert(Array.isArray(config.moduleSet?.modules)); config.moduleSet.modules = [...config.moduleSet.modules.filter(m => m.id !== module.id), { id: module.id, dockerImage: image, defaultParameters: defaults }]; config.runtimeImages = [...(config.runtimeImages || []).filter(a => a.image !== image), ...release.runtimeImages]; await tx.courseEnvironmentProfile.update({ where: { id: profile.id }, data: { config } }); summary.push({ course: c.title, week: 3, module: module.name, id: module.id, target: config.target }); } const after = await snapshot(tx); for (const old of before) { const current = after.find(c => c.id === old.id); assert.deepEqual(current.weeks.filter(w => w.week !== 3), old.weeks.filter(w => w.week !== 3)); assert.deepEqual(current.environmentProfiles.filter(p => p.week !== 3 || p.config.target !== 'remote-jetson'), old.environmentProfiles.filter(p => p.week !== 3 || p.config.target !== 'remote-jetson')); for (const module of old.weeks.find(w => w.week === 3).modules.filter(m => m.folderName !== folderName)) { assert.deepEqual(current.weeks.find(w => w.week === 3).modules.find(m => m.id === module.id), module); } } if (!apply) throw new Error('DRY_RUN_ROLLBACK'); }, { isolationLevel: 'Serializable', timeout: 30000 }); } catch (e) { if (e.message !== 'DRY_RUN_ROLLBACK') throw e; } console.log(JSON.stringify({ applied: apply, courses: summary }, null, 2)); } main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());