chore(demo): add reproducible fallback screenshots

This commit is contained in:
2026-07-09 22:20:54 +08:00
parent 0307bb4c35
commit 6e7db51b82
2 changed files with 231 additions and 1 deletions
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const adminUrl = process.env.ADMIN_URL || 'https://wygj-admin.localhost';
const outDir = process.argv.includes('--out')
? path.resolve(process.argv[process.argv.indexOf('--out') + 1])
: '/tmp';
const chromeBin = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const clientid = readFrontendEnv().VITE_APP_CLIENT_ID;
const pages = [
['wygj-ai-interview-flow.png', '/recruit/interview', 'AI面试'],
['wygj-practice-flow.png', '/train/practice', '三角色对练'],
['wygj-cases-flow.png', '/knowledge/cases', '案例沉淀'],
['wygj-sop-flow.png', '/knowledge/sop', 'SOP知识库']
];
if (!fs.existsSync(chromeBin)) {
throw new Error(`Chrome not found: ${chromeBin}`);
}
fs.mkdirSync(outDir, { recursive: true });
const token = await login();
const chrome = await launchChrome();
try {
const page = await connectPage(chrome.port);
await page.send('Page.enable');
await page.send('Runtime.enable');
await page.send('Emulation.setDeviceMetricsOverride', {
width: 1440,
height: 1000,
deviceScaleFactor: 1,
mobile: false
});
await page.send('Page.addScriptToEvaluateOnNewDocument', {
source: `localStorage.setItem('Admin-Token', ${JSON.stringify(token)});`
});
for (const [fileName, route, marker] of pages) {
const target = `${adminUrl}${route}`;
await page.navigate(target);
await page.waitForText(marker, 15000);
await page.sleep(800);
const screenshot = await page.send('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true
});
fs.writeFileSync(path.join(outDir, fileName), Buffer.from(screenshot.data, 'base64'));
console.log(`${path.join(outDir, fileName)} ${target}`);
}
} finally {
chrome.proc.kill('SIGTERM');
try {
fs.rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
} catch {
// ponytail: temp profile cleanup is best-effort; the OS temp sweeper can remove leftovers.
}
}
function readFrontendEnv() {
const env = {};
for (const line of fs.readFileSync(path.join(root, 'frontend/.env.development'), 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (match) env[match[1]] = match[2].replace(/^[ '"]+|[ '"]+$/g, '');
}
return env;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
function aesEncrypt(text, keyText) {
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64');
}
function aesDecrypt(base64Text, key) {
const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8');
}
async function login() {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const env = readFrontendEnv();
const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY);
const request = async (method, endpoint, body, encrypted = false) => {
const headers = { clientid, 'Content-Language': 'zh_CN' };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
payload = aesEncrypt(JSON.stringify(body), keyText);
headers['encrypt-key'] = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
).toString('base64');
} else {
payload = JSON.stringify(body);
}
}
const response = await fetch(`${adminUrl.replace('wygj-admin', 'wygj-api')}${endpoint}`, { method, headers, body: payload });
const text = await response.text();
if (!response.ok) throw new Error(`request failed ${endpoint}: ${response.status}`);
if (!response.headers.get('encrypt-key')) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(response.headers.get('encrypt-key'), 'base64')
).toString('utf8');
return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64')));
};
const codeResponse = await request('GET', '/auth/code');
const uuid = codeResponse.data?.uuid;
const code = execFileSync(
'docker',
['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim().split('\n').pop().replace(/^"|"$/g, '');
const loginResponse = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code,
uuid,
clientId: clientid,
grantType: 'password'
}, true);
const token = loginResponse.data?.access_token;
if (!token) throw new Error('admin login failed');
return token;
}
async function launchChrome() {
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wygj-demo-chrome-'));
const proc = spawn(chromeBin, [
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--ignore-certificate-errors',
'--remote-debugging-port=0',
`--user-data-dir=${userDataDir}`,
'about:blank'
], { stdio: ['ignore', 'ignore', 'pipe'] });
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Chrome DevTools did not start')), 10000);
proc.stderr.on('data', (chunk) => {
const match = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
if (match) {
clearTimeout(timer);
resolve(Number(match[1]));
}
});
proc.on('exit', (code) => reject(new Error(`Chrome exited: ${code}`)));
});
return { proc, port, userDataDir };
}
async function connectPage(port) {
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const wsUrl = targets.find((target) => target.type === 'page')?.webSocketDebuggerUrl;
if (!wsUrl) throw new Error('Chrome page target missing');
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
let id = 0;
const pending = new Map();
const waiters = [];
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
message.error ? reject(new Error(message.error.message)) : resolve(message.result || {});
return;
}
waiters.splice(0).forEach((waiter) => waiter(message));
};
const api = {
send(method, params = {}) {
const callId = ++id;
ws.send(JSON.stringify({ id: callId, method, params }));
return new Promise((resolve, reject) => pending.set(callId, { resolve, reject }));
},
async navigate(url) {
await this.send('Page.navigate', { url });
await this.sleep(1000);
},
waitForEvent(method, timeout) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeout);
waiters.push((message) => {
if (message.method === method) {
clearTimeout(timer);
resolve(message);
}
});
});
},
async waitForText(text, timeout) {
const started = Date.now();
while (Date.now() - started < timeout) {
const result = await this.send('Runtime.evaluate', {
expression: `document.body && document.body.innerText.includes(${JSON.stringify(text)})`,
returnByValue: true
});
if (result.result?.value) return;
await this.sleep(300);
}
throw new Error(`page text not found: ${text}`);
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
};
return api;
}