fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium, request as playwrightRequest } from 'playwright';
|
||||
|
||||
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
|
||||
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
|
||||
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
|
||||
const timeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_TIMEOUT_MS || 30000);
|
||||
const stepTimeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_STEP_TIMEOUT_MS || 10000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
const headless = process.env.LISGLOSIPS_WEB_UI_HEADLESS !== '0';
|
||||
const browserChannel = process.env.LISGLOSIPS_WEB_UI_CHANNEL || '';
|
||||
|
||||
const coreMenus = [
|
||||
'概览 Dashboard',
|
||||
'客户管理',
|
||||
'客户网关管理',
|
||||
'业务前缀管理',
|
||||
'充值记录',
|
||||
'供应商管理',
|
||||
'落地网关管理',
|
||||
'落地线路组',
|
||||
'号码库',
|
||||
'当前通话',
|
||||
'话单中心',
|
||||
'质检中心',
|
||||
'用户管理',
|
||||
'角色与权限',
|
||||
'操作日志',
|
||||
];
|
||||
|
||||
const safeActionsByMenu = {
|
||||
'概览 Dashboard': ['刷新'],
|
||||
'客户管理': ['编辑'],
|
||||
'客户网关管理': ['编辑'],
|
||||
'业务前缀管理': ['编辑'],
|
||||
'充值记录': ['刷新'],
|
||||
'供应商管理': ['编辑'],
|
||||
'落地网关管理': ['编辑'],
|
||||
'落地线路组': ['编辑'],
|
||||
'号码库': ['刷新'],
|
||||
'当前通话': ['刷新通话'],
|
||||
'话单中心': ['查看详情'],
|
||||
'质检中心': ['查看详情', '刷新'],
|
||||
'用户管理': ['编辑'],
|
||||
'角色与权限': ['编辑'],
|
||||
'操作日志': ['查看详情'],
|
||||
};
|
||||
|
||||
if (!password) {
|
||||
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function decodeCaptcha(imageDataUrl) {
|
||||
const encoded = String(imageDataUrl || '').split(',', 2)[1];
|
||||
if (!encoded) {
|
||||
return '';
|
||||
}
|
||||
const svg = Buffer.from(encoded, 'base64').toString('utf8');
|
||||
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
|
||||
}
|
||||
|
||||
function parseSetCookie(value) {
|
||||
const firstCookie = Array.isArray(value) ? value[0] : String(value || '').split('\n')[0];
|
||||
const [pair, ...attributes] = firstCookie.split(';').map((item) => item.trim());
|
||||
const index = pair.indexOf('=');
|
||||
if (index <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookie = {
|
||||
name: pair.slice(0, index),
|
||||
value: pair.slice(index + 1),
|
||||
url: baseUrl,
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: baseUrl.startsWith('https://'),
|
||||
sameSite: 'Lax',
|
||||
};
|
||||
|
||||
for (const attribute of attributes) {
|
||||
const [rawName, rawValue] = attribute.split('=');
|
||||
const name = rawName.toLowerCase();
|
||||
if (name === 'path' && rawValue) cookie.path = rawValue;
|
||||
if (name === 'httponly') cookie.httpOnly = true;
|
||||
if (name === 'secure') cookie.secure = true;
|
||||
if (name === 'samesite' && rawValue && ['Strict', 'Lax', 'None'].includes(rawValue)) cookie.sameSite = rawValue;
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
function reportLine(check) {
|
||||
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
async function loginThroughUi(page) {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
||||
const usernameInput = page.getByLabel('用户名');
|
||||
const firstScreen = await Promise.race([
|
||||
usernameInput.waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'login').catch(() => null),
|
||||
page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'app').catch(() => null),
|
||||
]);
|
||||
if (firstScreen === 'app') {
|
||||
return { skipped: true, captchaCodeLength: 0 };
|
||||
}
|
||||
if (firstScreen !== 'login') {
|
||||
throw new Error('Neither login form nor authenticated app shell became visible.');
|
||||
}
|
||||
|
||||
const captchaImage = page.locator('.captcha-image img');
|
||||
await captchaImage.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
const captchaCode = decodeCaptcha(await captchaImage.getAttribute('src'));
|
||||
await usernameInput.fill(username);
|
||||
await page.getByLabel('密码').fill(password);
|
||||
await page.getByLabel('图形验证码').fill(captchaCode);
|
||||
await page.getByRole('button', { name: '登录' }).click();
|
||||
await page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
return { skipped: false, captchaCodeLength: captchaCode.length };
|
||||
}
|
||||
|
||||
async function loginByApi() {
|
||||
const api = await playwrightRequest.newContext({
|
||||
baseURL: baseUrl,
|
||||
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
|
||||
extraHTTPHeaders: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'lisglosips-remote-web-ui-smoke/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const captchaResponse = await api.get('/api/v2/auth/captcha', { timeout: timeoutMs });
|
||||
const captcha = await captchaResponse.json();
|
||||
const captchaCode = decodeCaptcha(captcha.imageDataUrl);
|
||||
const loginResponse = await api.post('/api/v2/auth/login', {
|
||||
timeout: timeoutMs,
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
const loginBody = await loginResponse.json().catch(() => null);
|
||||
return {
|
||||
ok: loginResponse.ok(),
|
||||
status: loginResponse.status(),
|
||||
accessToken: loginBody?.accessToken,
|
||||
user: loginBody?.user,
|
||||
refreshCookie: parseSetCookie(loginResponse.headers()['set-cookie']),
|
||||
captchaCodeLength: captchaCode.length,
|
||||
};
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkMenu(page, label) {
|
||||
const beforeErrors = errorEvents.length;
|
||||
const button = page.locator('button').filter({ hasText: label }).first();
|
||||
const found = await button.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
|
||||
if (!found) {
|
||||
return {
|
||||
name: `${label} menu is visible`,
|
||||
pass: false,
|
||||
detail: 'menu button not found or not visible',
|
||||
};
|
||||
}
|
||||
await button.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
|
||||
await page.getByText(label, { exact: true }).first().waitFor({ state: 'visible', timeout: stepTimeoutMs }).catch(() => {});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
|
||||
const afterErrors = errorEvents.slice(beforeErrors);
|
||||
const visibleTextLength = bodyText.replace(/\s+/g, '').length;
|
||||
const hasApiUnavailable = bodyText.includes('API 数据不可用');
|
||||
const isLoginScreen = bodyText.includes('请输入用户名') && bodyText.includes('图形验证码');
|
||||
|
||||
return {
|
||||
name: `${label} renders without browser errors`,
|
||||
pass: afterErrors.length === 0 && visibleTextLength > 80 && !hasApiUnavailable && !isLoginScreen,
|
||||
detail: [
|
||||
`textLength=${visibleTextLength}`,
|
||||
hasApiUnavailable ? 'api-unavailable' : 'api-ok',
|
||||
isLoginScreen ? 'login-screen' : 'authenticated',
|
||||
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
|
||||
].join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
async function closeOverlay(page) {
|
||||
const closeButtons = [
|
||||
page.getByRole('button', { name: '取消' }),
|
||||
page.getByRole('button', { name: '关闭' }),
|
||||
page.getByRole('button', { name: '收起' }),
|
||||
];
|
||||
|
||||
for (const closeButton of closeButtons) {
|
||||
const count = await closeButton.count().catch(() => 0);
|
||||
if (count > 0 && await closeButton.first().isVisible().catch(() => false)) {
|
||||
await closeButton.first().click().catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function verifyCustomerGatewayPrefixToggle(page) {
|
||||
const modalVisible = await page.getByRole('dialog', { name: '编辑客户网关' }).isVisible({ timeout: stepTimeoutMs }).catch(() => false);
|
||||
if (!modalVisible) {
|
||||
return 'customer-gateway-edit-modal-not-visible';
|
||||
}
|
||||
|
||||
const calleeMode = page.locator('select').filter({ has: page.locator('option[value="BUSINESS_PREFIXES"]') }).first();
|
||||
const canSelectMode = await calleeMode.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
|
||||
if (!canSelectMode) {
|
||||
return 'business-prefix-mode-select-not-visible';
|
||||
}
|
||||
|
||||
await calleeMode.selectOption('BUSINESS_PREFIXES');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const firstPrefixInput = page.locator('.checkbox-grid input[type="checkbox"]').first();
|
||||
const hasPrefix = await firstPrefixInput.count().catch(() => 0);
|
||||
if (!hasPrefix) {
|
||||
return 'no-business-prefix-option; skipped';
|
||||
}
|
||||
|
||||
const firstPrefixLabel = page.locator('.checkbox-grid .ui-check').first();
|
||||
const before = await firstPrefixInput.isChecked();
|
||||
await firstPrefixLabel.click();
|
||||
await page.waitForTimeout(100);
|
||||
const afterFirstClick = await firstPrefixInput.isChecked();
|
||||
await firstPrefixLabel.click();
|
||||
await page.waitForTimeout(100);
|
||||
const afterSecondClick = await firstPrefixInput.isChecked();
|
||||
|
||||
if (afterFirstClick === before || afterSecondClick !== before) {
|
||||
throw new Error(`customer gateway prefix checkbox did not toggle correctly: before=${before}, afterFirst=${afterFirstClick}, afterSecond=${afterSecondClick}`);
|
||||
}
|
||||
|
||||
return `business-prefix-checkbox-toggle-ok: ${before}->${afterFirstClick}->${afterSecondClick}`;
|
||||
}
|
||||
|
||||
async function checkSafeAction(page, label) {
|
||||
const actions = safeActionsByMenu[label] || [];
|
||||
if (!actions.length) {
|
||||
return {
|
||||
name: `${label} safe action is configured`,
|
||||
pass: true,
|
||||
detail: 'no action configured',
|
||||
};
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
const locator = page.getByRole('button', { name: action }).first();
|
||||
const count = await locator.count().catch(() => 0);
|
||||
const visible = count > 0 && await locator.isVisible().catch(() => false);
|
||||
const enabled = visible && await locator.isEnabled().catch(() => false);
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeErrors = errorEvents.length;
|
||||
await locator.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
const actionDetails = [];
|
||||
if (label === '客户网关管理' && action === '编辑') {
|
||||
actionDetails.push(await verifyCustomerGatewayPrefixToggle(page));
|
||||
}
|
||||
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
|
||||
const afterErrors = errorEvents.slice(beforeErrors);
|
||||
await closeOverlay(page);
|
||||
|
||||
return {
|
||||
name: `${label} action ${action} works without browser errors`,
|
||||
pass: afterErrors.length === 0 && !bodyText.includes('API 数据不可用'),
|
||||
detail: [
|
||||
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
|
||||
...actionDetails,
|
||||
].join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${label} safe action is available`,
|
||||
pass: true,
|
||||
detail: `no visible enabled action among ${actions.join('/')}; skipped`,
|
||||
};
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
const errorEvents = [];
|
||||
const login = await loginByApi();
|
||||
|
||||
checks.push({
|
||||
name: 'API login succeeds for browser smoke',
|
||||
pass: login.ok && typeof login.accessToken === 'string' && Boolean(login.refreshCookie),
|
||||
detail: `status=${login.status}, captchaLength=${login.captchaCodeLength}, permissionCount=${Array.isArray(login.user?.permissions) ? login.user.permissions.length : 0}`,
|
||||
});
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
|
||||
if (checks[0].pass) {
|
||||
browser = await chromium.launch({ headless, ...(browserChannel ? { channel: browserChannel } : {}) });
|
||||
context = await browser.newContext({
|
||||
baseURL: baseUrl,
|
||||
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
errorEvents.push({ source: 'console', message: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
errorEvents.push({ source: 'pageerror', message: error.message });
|
||||
});
|
||||
|
||||
const uiLogin = await loginThroughUi(page);
|
||||
checks.push({
|
||||
name: 'UI login succeeds before menu smoke',
|
||||
pass: uiLogin.skipped || uiLogin.captchaCodeLength >= 4,
|
||||
detail: uiLogin.skipped ? 'already authenticated' : `captchaLength=${uiLogin.captchaCodeLength}`,
|
||||
});
|
||||
|
||||
for (const label of coreMenus) {
|
||||
console.log(`CHECK ${label}`);
|
||||
const menuCheck = await checkMenu(page, label);
|
||||
checks.push(menuCheck);
|
||||
if (menuCheck.pass) {
|
||||
console.log(`ACTION ${label}`);
|
||||
checks.push(await checkSafeAction(page, label));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context?.close();
|
||||
await browser?.close();
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_WEB_UI_SMOKE_${stamp}.md`);
|
||||
const failed = checks.filter((check) => !check.pass);
|
||||
|
||||
const report = [
|
||||
'# Remote Web UI Smoke Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Headless: ${headless}`,
|
||||
`Browser Channel: ${browserChannel || 'playwright-default'}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Guardrails',
|
||||
'',
|
||||
'- Fails on browser `pageerror` and console `error` events.',
|
||||
'- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.',
|
||||
'- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(reportPath, report, 'utf8');
|
||||
|
||||
for (const check of checks) {
|
||||
console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name} - ${check.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user