All checks were successful
PR checks / checks (pull_request) Successful in 2m55s
Nine captures tagged rust (D65): the server list, feed, leaderboard, live map, permissions and the RunicNPC profile editor on the web, and a server's feed, its map and the player's Rust account in the app, from an Android-app edge debug build. D80: every name in a capture is a role (admin, moderator, player...), from this phase on. D81: the Rust demo's players are mock data written by scripts/mockRustDemo.mjs; the server, its status, map, permission groups and RunicNPC sync state are the real Oxide test server's. The captions and /modules/rust/ and /app/ say which part is a demo cast. seedDemo.mjs gains --game rust (server row with a weekly wipe, public presence for the demo, two NPC profiles, a VIP group). captureScreens.mjs gains a click list per screen. The homepage shows the Rust live map beside the UO capture (D76); three Rust docs pages carry their screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
207 lines
8.4 KiB
JavaScript
207 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
|
|
*
|
|
* PLAN.md §13 phase 9, D4 / D45.
|
|
*
|
|
* node scripts/captureScreens.mjs # every web screen
|
|
* node scripts/captureScreens.mjs shard-status admin-users
|
|
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
|
|
* ---------------------------------------------------------------------------------------
|
|
* This never runs in CI and CI never needs it: its output is committed, because the site
|
|
* must build from a clean checkout with no game server, no database and no browser. What
|
|
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
|
|
*
|
|
* It exists because D4 asks for real screenshots of a real deployment, and the way real
|
|
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
|
|
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
|
|
* sign in — is written down in `screens.mjs`; and this turns the two into files.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* WHY puppeteer-core AND NOT puppeteer
|
|
* ---------------------------------------------------------------------------------------
|
|
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
|
|
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
|
|
* a Chrome that is already on the machine, which every machine that can look at this site
|
|
* has. Point `RG_CHROME` at it if it is somewhere unusual.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
|
|
* ---------------------------------------------------------------------------------------
|
|
* The administration screens need a session, and typing into the login form is the part of
|
|
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
|
|
* React input that ignores synthetic typing. The session cookie is the only thing actually
|
|
* wanted, so this asks the API for one from inside the page and lets the browser store it.
|
|
* If that call stops returning 200 the script says so and stops, rather than quietly
|
|
* screenshotting a login screen twelve times.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import sharp from 'sharp';
|
|
|
|
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const OUT = path.join(HERE, '..', 'public', 'screens');
|
|
|
|
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
|
|
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
|
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
|
|
|
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
|
|
const CHROME_CANDIDATES = [
|
|
process.env.RG_CHROME,
|
|
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/usr/bin/google-chrome',
|
|
'/usr/bin/chromium',
|
|
].filter(Boolean);
|
|
|
|
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
|
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
|
|
|
|
if (todo.length === 0) {
|
|
const known = screens.map((shot) => shot.id).join(', ');
|
|
console.error(`Nothing to capture. Known ids: ${known}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
|
|
|
|
if (!chrome) {
|
|
console.error(
|
|
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
|
|
'installed Chrome rather than downloading one (see the header).',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
|
|
mkdirSync(OUT, { recursive: true });
|
|
|
|
const browser = await puppeteer.launch({
|
|
executablePath: chrome,
|
|
headless: 'new',
|
|
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
|
|
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
|
|
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
|
|
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
|
|
});
|
|
|
|
/**
|
|
* One page per privilege level rather than signing in and out around each shot: signing
|
|
* out is the step that gets forgotten, and a public page captured with an admin session
|
|
* shows a navigation bar the public never sees.
|
|
*/
|
|
const anon = await browser.newPage();
|
|
const admin = await browser.newPage();
|
|
|
|
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
|
|
|
|
const status = await admin.evaluate(
|
|
async (username, password) => {
|
|
const res = await fetch('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
return res.status;
|
|
},
|
|
USER,
|
|
PASS,
|
|
);
|
|
|
|
if (status !== 200) {
|
|
console.error(
|
|
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
|
|
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
|
|
);
|
|
await browser.close();
|
|
process.exit(1);
|
|
}
|
|
|
|
let failures = 0;
|
|
|
|
for (const shot of todo) {
|
|
const page = shot.admin ? admin : anon;
|
|
const url = BASE + shot.route;
|
|
|
|
try {
|
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
|
|
|
// A screen whose subject is behind a tab or a button the route cannot reach. Each step is
|
|
// the visible text the element starts with; the first visible button, link or tab that
|
|
// matches is clicked. A step that matches nothing fails the shot rather than capturing
|
|
// the wrong screen.
|
|
for (const label of shot.click ?? []) {
|
|
const clicked = await page.evaluate((text) => {
|
|
const candidates = [...document.querySelectorAll('button, a, [role="tab"], [role="button"], li')];
|
|
const hit = candidates.find(
|
|
(el) => el.offsetParent !== null && el.textContent.trim().startsWith(text),
|
|
);
|
|
if (hit) hit.click();
|
|
return Boolean(hit);
|
|
}, label);
|
|
if (!clicked) throw new Error(`nothing to click starting "${label}"`);
|
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
}
|
|
|
|
if (shot.scrollY) {
|
|
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
|
|
}
|
|
|
|
// Live pages settle after their first paint: a shard panel fills in from an event
|
|
// stream, a list re-sorts once its data lands. A second is cheap and the difference
|
|
// between a screenshot of the product and a screenshot of its loading state.
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
|
|
const png = await page.screenshot({ type: 'png' });
|
|
const file = path.join(OUT, `${shot.id}.webp`);
|
|
|
|
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
|
|
// which is what makes a page with five of them still a page and not a download.
|
|
await sharp(png).webp({ quality: 82 }).toFile(file);
|
|
|
|
const meta = await sharp(file).metadata();
|
|
|
|
if (meta.width !== WEB.width || meta.height !== WEB.height) {
|
|
console.error(
|
|
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
|
|
);
|
|
failures++;
|
|
continue;
|
|
}
|
|
|
|
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
|
|
} catch (err) {
|
|
console.error(` ! ${shot.id}: ${err.message}`);
|
|
failures++;
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// A file left behind by a screen that has since been renamed or dropped is a file the
|
|
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
|
|
if (wanted.length === 0) {
|
|
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
|
|
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
|
|
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
|
|
|
|
if (orphans.length > 0) {
|
|
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
|
|
process.exit(failures > 0 ? 1 : 0);
|