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>/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; }