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.
129 lines
4.3 KiB
TypeScript
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);
|
|
});
|