#!/usr/bin/env node /** * Validate the generated 15-module course page and its media. * * Usage: * node tools/web/course/check-full-course.mjs */ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const DIR = path.join(REPO, "web/app/public/tutorial/full"); const INDEX = path.join(DIR, "index.html"); const errors = []; if (!fs.existsSync(INDEX)) errors.push("missing index.html"); const html = fs.existsSync(INDEX) ? fs.readFileSync(INDEX, "utf8") : ""; const modules = [...html.matchAll(/class="course-module"/g)].length; if (modules !== 15) errors.push(`expected 15 modules, found ${modules}`); if (!html.includes("OPFS")) errors.push("course does not mention OPFS"); if (/ { if (!src || /^https?:/i.test(src)) return; const file = src.startsWith("/") ? path.join(REPO, "web/app/public", src.slice(1)) : path.join(DIR, src); if (!fs.existsSync(file)) errors.push(`missing asset ${src}`); }; for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); const media = []; for (const match of html.matchAll(/]+src="([^"]+)"/g)) { const file = match[1].startsWith("/") ? path.join(REPO, "web/app/public", match[1].slice(1)) : path.join(DIR, match[1]); if (!fs.existsSync(file)) continue; const probe = JSON.parse(execFileSync("/usr/bin/ffprobe", ["-v", "error", "-show_entries", "format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" })); const video = probe.streams.find((stream) => stream.codec_type === "video") ?? {}; const audio = probe.streams.find((stream) => stream.codec_type === "audio") ?? {}; media.push({ src: match[1], duration: Number(probe.format?.duration ?? 0), bytes: Number(probe.format?.size ?? 0), width: video.width, height: video.height, videoCodec: video.codec_name, audioCodec: audio.codec_name }); if (video.codec_name !== "h264" || audio.codec_name !== "aac") errors.push(`${match[1]} is not H.264/AAC`); if (video.width !== 1280 || video.height !== 720) errors.push(`${match[1]} is not 1280x720`); } const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, videos: media.length, media, errors }; console.log(JSON.stringify(result, null, 2)); if (errors.length > 0) process.exitCode = 1;