Files
Josh c602b8f8f3 docs: verify against real WHP + capture real screenshots
Discovery against the demo account on whp01 surfaced several inaccuracies:

- Cache is Valkey (Redis wire-compatible), not Redis or Memcached.
  No Memcached is offered as a separate service.
- Site Monitoring is the sidebar label (not 'AI Monitor').
- 'Add a domain' has no Primary/Add-on distinction.
- Sites form: 'Container Type' (not 'Site type'), Number of Containers
  (1-10 for horizontal scaling), CPU per Container (default 0.25),
  Memory per Container (default 256MB), SSL inline on the same form.
- Backups: default retention 5 days / 10 backups; on-demand + scheduled;
  S3 backup targets are visible and configurable.
- Email: per-domain settings live behind 'Setup Instructions' on the
  Email page; mail server hostname is on the Dashboard (per-server,
  e.g. mail01.cloud-hosting.io), not per-domain.

Also reworked the screenshot pipeline:
- New shots.config.ts targets the real index.php?page=... URLs
- Added redactSensitive() step that runs before each screenshot to swap
  server names, IPs, mail hostnames, and demo-user-isms with neutral
  placeholders. This keeps docs portable across the fleet.
- Hides .brand-full and .navbar-text (top-bar server identifier and
  Welcome greeting).
- Captured 9 real WHP screenshots; removed stale placeholders.
2026-05-17 17:00:13 -07:00

129 lines
4.3 KiB
TypeScript

import { chromium, type Page, type Locator } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_MASK, shots, type Shot } from './shots.config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp');
function envOrDie(name: string): string {
const v = process.env[name];
if (!v) {
throw new Error(
`Missing env var: ${name}. Put it in tools/screenshots/.env (gitignored).`,
);
}
return v;
}
const WHP_BASE = envOrDie('WHP_BASE'); // e.g., https://whp01.cloud-hosting.io:8443
const WHP_USER = envOrDie('WHP_USER');
const WHP_PASS = envOrDie('WHP_PASS');
async function login(page: Page): Promise<void> {
await page.goto(`${WHP_BASE}/login.php`);
await page.fill('input[name="user"]', WHP_USER);
await page.fill('input[name="password"]', WHP_PASS);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
}
/**
* Neutralise server-identifying and account-specific text/labels before the
* screenshot — we run a multi-server fleet, so docs shouldn't bake in a single
* host's name or IP.
*
* Runs in the page context. Self-contained: no external bindings, no eval.
*/
async function redactSensitive(page: Page): Promise<void> {
await page.addStyleTag({
content: `
.navbar-text { visibility: hidden !important; }
.brand-full { visibility: hidden !important; }
`,
});
// String swaps for inline text that mask-by-selector can't cover.
await page.evaluate(() => {
const root = document.body;
const swaps: [RegExp, string][] = [
// server / mail / nameserver hostnames (with possible -s3 etc. suffix)
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
[/mail\d+\.cloud-hosting\.io/gi, '<your-mail-server>.cloud-hosting.io'],
[/ns[12]\.whp\d+\.cloud-hosting\.io/gi, 'ns<n>.your-server.cloud-hosting.io'],
// backup target / bucket names that bake in server number
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
// IP addresses
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<your-server-IP>'],
// home dir + demo user
[/\/docker\/users\/[a-z0-9-]+/g, '/docker/users/<your-username>'],
[/demo-user/g, 'your-username'],
];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n: Node | null = walker.nextNode();
while (n) { nodes.push(n as Text); n = walker.nextNode(); }
for (const node of nodes) {
let v = node.nodeValue ?? '';
for (const [re, replacement] of swaps) v = v.replace(re, replacement);
if (v !== node.nodeValue) node.nodeValue = v;
}
});
}
async function captureShot(page: Page, shot: Shot): Promise<void> {
const viewport = shot.viewport ?? { width: 1440, height: 900 };
await page.setViewportSize(viewport);
await page.goto(`${WHP_BASE}${shot.path}`);
await page.waitForLoadState('networkidle');
if (shot.waitFor) await page.waitForSelector(shot.waitFor);
await redactSensitive(page);
const maskSelectors = [...DEFAULT_MASK, ...(shot.mask ?? [])];
const maskLocators: Locator[] = maskSelectors.map((sel) => page.locator(sel));
const outPath = resolve(OUT_DIR, `${shot.id}.png`);
if (shot.selector) {
await page.locator(shot.selector).screenshot({
path: outPath,
mask: maskLocators,
});
} else {
// Viewport-only — Playwright never includes browser chrome
await page.screenshot({
path: outPath,
fullPage: false,
mask: maskLocators,
});
}
console.log(`captured ${shot.id} -> ${outPath}`);
}
async function main(): Promise<void> {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
try {
await login(page);
for (const shot of shots) {
await captureShot(page, shot);
}
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});