chore(demo): add reproducible fallback screenshots
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user