#!/usr/bin/env node /** * Phone-width screenshots of the Phase 5 dashboard. * * Evidence for AUD-109: the per-child dashboard (overdue first) and the item * detail screen at a phone width (390 x 844). It logs into the live service with * the dashboard session login, then follows the child tabs and the first overdue * item. * * Usage (secrets from the environment, never committed): * SCHOOL_USER=sam SCHOOL_PASS=... node scripts/screenshot-phase5.mjs * * Environment: * SCHOOL_BASE default https://school.lab.audasmedia.com.au * SCHOOL_SHOT_DIR default var/screenshots (git-ignored) * COMPASS_CHROME default /etc/profiles/per-user/sam/bin/google-chrome * * The output is personal data (children's homework). It stays under var/ * (git-ignored) and is only uploaded as issue evidence. */ import { chromium } from "patchright"; import fs from "node:fs"; import path from "node:path"; const BASE = (process.env.SCHOOL_BASE || "https://school.lab.audasmedia.com.au").replace(/\/$/, ""); const USER = process.env.SCHOOL_USER; const PASS = process.env.SCHOOL_PASS; const OUT = path.resolve(process.env.SCHOOL_SHOT_DIR || "var/screenshots"); const CHROME = process.env.COMPASS_CHROME || "/etc/profiles/per-user/sam/bin/google-chrome"; if (!USER || !PASS) { console.error("school: SCHOOL_USER and SCHOOL_PASS are required"); process.exit(2); } const VIEWPORT = { width: 390, height: 844 }; // iPhone 14/15 class width const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-"); fs.mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: CHROME, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 2, locale: "en-AU", timezoneId: "Australia/Sydney", }); const page = await context.newPage(); try { // 1. Sign in. await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); await page.fill('input[name="username"]', USER); await page.fill('input[name="password"]', PASS); await Promise.all([ page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 20000 }), page.click('button[type="submit"]'), ]); await page.waitForSelector(".child-tabs, .empty-state", { timeout: 20000 }); // 2. One full-page shot per child, with that child selected. const tabs = await page.$$eval(".child-tab", (nodes) => nodes.map((n) => ({ name: n.textContent.trim().split("\n")[0].trim(), href: n.getAttribute("href") })), ); const shots = []; for (const tab of tabs) { await page.goto(`${BASE}${tab.href}`, { waitUntil: "networkidle" }); await page.waitForSelector(".child", { timeout: 20000 }); const file = path.join(OUT, `dashboard-${slug(tab.name)}.png`); await page.screenshot({ path: file, fullPage: true }); shots.push(file); console.log(`shot ${file}`); // The top of the page at phone width: the scan banner, the tabs and the // overdue list, which is what Sam sees first. const topFile = path.join(OUT, `dashboard-${slug(tab.name)}-top.png`); await page.screenshot({ path: topFile }); shots.push(topFile); console.log(`shot ${topFile}`); // 3. Item detail of the first overdue item, when the child has one. const link = await page.$("#sec-overdue .item__title a"); if (link) { await Promise.all([page.waitForLoadState("networkidle"), link.click()]); await page.waitForSelector(".detail", { timeout: 20000 }); const detailFile = path.join(OUT, `item-detail-${slug(tab.name)}.png`); await page.screenshot({ path: detailFile, fullPage: true }); shots.push(detailFile); console.log(`shot ${detailFile}`); } else { console.log(`no overdue item for ${tab.name}`); } } // 4. The scan banner on its own, cropped to the top of the dashboard. The // class is shared with the calendar banner, so select by the labelled // section, not by `.scan` alone. await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); const scanBanner = page.locator('.scan[aria-labelledby="scan-title"]'); await scanBanner.waitFor({ timeout: 20000 }); const bannerFile = path.join(OUT, "scan-banner.png"); await scanBanner.screenshot({ path: bannerFile }); shots.push(bannerFile); console.log(`shot ${bannerFile}`); // 5. The calendar banner (Phase 6, FR-8 / FR-12). const calendarBanner = page.locator('.scan[aria-labelledby="calendar-title"]'); await calendarBanner.waitFor({ timeout: 20000 }); const calendarFile = path.join(OUT, "calendar-banner.png"); await calendarBanner.screenshot({ path: calendarFile }); shots.push(calendarFile); console.log(`shot ${calendarFile}`); await sleep(100); console.log(`done: ${shots.length} screenshot(s) in ${OUT}`); } finally { await browser.close(); }