ktworkflow-mcp/scripts/admin-login-smoke.ts

396 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
import { mkdirSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
interface CliOptions {
headless: boolean;
password: string;
screenshotPath: string;
storageStatePath: string;
targetUrl: string;
timeout: number;
url: string;
username: string;
}
const dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(
process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../..'),
);
const adminRoot = path.join(workspaceRoot, 'Vue/kt-template-admin');
const defaultArtifactDir = path.join(workspaceRoot, '.codex-verify/admin-login');
/**
* @param value 原始命令行布尔值。
* @returns 将 CLI 字符串转换为布尔值。
*/
function toBoolean(value: string | undefined): boolean {
if (!value) return true;
return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
}
/**
* @param args 已解析的命令行参数。
* @param key 布尔参数名。
* @param envValue 对应环境变量值。
* @param defaultValue 未传参且无环境变量时的默认值。
* @returns 最终布尔值。
*/
function getBooleanArg(
args: Map<string, string | undefined>,
key: string,
envValue: string | undefined,
defaultValue: boolean,
): boolean {
if (args.has(key)) return toBoolean(args.get(key));
if (envValue !== undefined) return toBoolean(envValue);
return defaultValue;
}
/**
* @param value 原始路径,允许相对 KT 根目录。
* @returns 解析后的绝对路径。
*/
function resolveWorkspacePath(value: string): string {
return path.isAbsolute(value) ? value : path.resolve(workspaceRoot, value);
}
/**
* @param argv 传入脚本的命令行参数。
* @returns 登录脚本配置。
*/
function parseArgs(argv: string[]): CliOptions {
const args = new Map<string, string | undefined>();
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (!item.startsWith('--')) continue;
const [key, inlineValue] = item.slice(2).split('=', 2);
if (inlineValue !== undefined) {
args.set(key, inlineValue);
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(key, next);
index += 1;
} else {
args.set(key, undefined);
}
}
if (args.has('help') || args.has('h')) {
printHelp();
process.exit(0);
}
const storageStatePath = resolveWorkspacePath(
args.get('storage-state') ||
process.env.KT_ADMIN_STORAGE_STATE ||
path.join(defaultArtifactDir, 'admin-storage-state.json'),
);
const screenshotPath = resolveWorkspacePath(
args.get('screenshot') ||
process.env.KT_ADMIN_LOGIN_SCREENSHOT ||
path.join(defaultArtifactDir, 'admin-login.png'),
);
return {
headless: getBooleanArg(
args,
'headless',
process.env.KT_ADMIN_HEADLESS,
false,
),
password: args.get('password') || process.env.KT_ADMIN_PASSWORD || '123456',
screenshotPath,
storageStatePath,
targetUrl: args.get('target') || process.env.KT_ADMIN_TARGET_URL || '/workspace',
timeout: Number(args.get('timeout') || process.env.KT_ADMIN_TIMEOUT || 30_000),
url:
args.get('url') ||
process.env.KT_ADMIN_URL ||
'http://127.0.0.1:5999/#/auth/login',
username: args.get('username') || process.env.KT_ADMIN_USERNAME || 'admin',
};
}
/**
* 输出脚本帮助文本。
*/
function printHelp(): void {
console.log(`KT Admin 登录冒烟脚本
用法:
pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
常用参数:
--username <name> 默认读取 KT_ADMIN_USERNAME兜底 admin
--password <password> 默认读取 KT_ADMIN_PASSWORD兜底 123456
--url <url> Admin 登录页 URL
--target <path-or-url> 登录后验证目标,默认 /workspace
--storage-state <path> 保存 Playwright storageState
--screenshot <path> 保存登录后截图
--headless=false 默认可见 Edge传 true 可无头运行
--timeout <ms> 默认 30000
`);
}
/**
* @param adminPackageRoot Admin 项目根目录。
* @returns 从 Admin 项目依赖中解析出的 Playwright。
*/
function resolvePlaywright(adminPackageRoot: string): any {
const adminRequire = createRequire(path.join(adminPackageRoot, 'package.json'));
try {
return adminRequire('playwright');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`未能从 kt-template-admin 解析 playwright。请先在 ${adminPackageRoot} 执行 pnpm install。原始错误: ${message}`,
);
}
}
/**
* @param target 需要确保父目录存在的文件路径。
*/
function ensureParentDir(target: string): void {
const parent = path.dirname(target);
if (!existsSync(parent)) {
mkdirSync(parent, { recursive: true });
}
}
/**
* @param page Playwright 页面对象。
* @param timeout 等待超时时间。
* @param username 登录用户名。
* @param password 登录密码。
*/
async function fillCredentials(
page: any,
timeout: number,
username: string,
password: string,
): Promise<void> {
const usernameInput = page
.getByPlaceholder(/请输入用户名|Please enter username/i)
.first();
await usernameInput.waitFor({ state: 'visible', timeout });
await usernameInput.fill(username);
const passwordInput = page.locator('input[type="password"]').first();
await passwordInput.waitFor({ state: 'visible', timeout });
await passwordInput.fill(password);
}
/**
* @param page Playwright 页面对象。
* @param timeout 等待超时时间。
*/
async function passSliderCaptcha(page: any, timeout: number): Promise<void> {
const action = page.locator('[name="captcha-action"]').first();
await action.waitFor({ state: 'visible', timeout });
const points = await action.evaluate((element: any) => {
const wrapper = element.parentElement;
if (!wrapper) {
throw new Error('未找到滑块父容器');
}
const actionBox = element.getBoundingClientRect();
const wrapperBox = wrapper.getBoundingClientRect();
const y = actionBox.top + actionBox.height / 2;
return {
endX: wrapperBox.right - 6,
startX: actionBox.left + actionBox.width / 2,
y,
};
});
await page.mouse.move(points.startX, points.y);
await page.mouse.down();
await page.mouse.move(points.endX, points.y, { steps: 24 });
await page.mouse.up();
await page
.getByText(/验证通过|Passed/i)
.first()
.waitFor({ state: 'visible', timeout: Math.min(timeout, 5_000) });
}
/**
* @param page Playwright 页面对象。
* @param timeout 等待超时时间。
* @returns 登录接口响应。
*/
async function clickLogin(page: any, timeout: number): Promise<any | null> {
const responsePromise = page
.waitForResponse(
(response: any) =>
response.request().method() === 'POST' &&
response.url().includes('/api/auth/login'),
{ timeout },
)
.catch(() => null);
const loginByName = page.getByRole('button', { name: /^login$/i }).first();
if (await loginByName.isVisible().catch(() => false)) {
await loginByName.click();
} else {
await page
.getByRole('button', { name: /登录|Login/i })
.first()
.click({ timeout });
}
return responsePromise;
}
/**
* @param baseUrl 当前登录页 URL。
* @param target 登录后目标,可以是绝对 URL、hash 路由或普通路径。
* @returns 可以传给 page.goto 的完整 URL。
*/
function resolveTargetUrl(baseUrl: string, target: string): string {
if (/^https?:\/\//i.test(target)) return target;
const url = new URL(baseUrl);
if (target.startsWith('#')) {
url.hash = target;
return url.toString();
}
url.hash = target.startsWith('/') ? target : `/${target}`;
return url.toString();
}
/**
* @param page Playwright 页面对象。
* @param timeout 等待超时时间。
*/
async function waitForLoggedIn(page: any, timeout: number): Promise<void> {
await page.waitForFunction(
() => {
const href = (globalThis as any).location.href;
return !href.includes('/auth/login') && !href.includes('#/login');
},
undefined,
{ timeout },
);
}
/**
* @param response 登录接口响应。
*/
async function assertLoginResponse(response: any | null): Promise<void> {
if (!response) return;
const status = response.status();
let body: Record<string, unknown> | null = null;
try {
body = await response.json();
} catch {
body = null;
}
if (status >= 400) {
throw new Error(`登录接口失败: HTTP ${status}`);
}
if (body && typeof body.code === 'number' && ![0, 200].includes(body.code)) {
throw new Error(
`登录接口失败: code=${body.code}, msg=${String(
body.msg || body.message || '',
)}`,
);
}
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const { chromium } = resolvePlaywright(adminRoot);
ensureParentDir(options.storageStatePath);
ensureParentDir(options.screenshotPath);
const browser = await chromium.launch({
channel: 'msedge',
headless: options.headless,
slowMo: options.headless ? 0 : 60,
});
const context = await browser.newContext({
viewport: { height: 960, width: 1440 },
});
const page = await context.newPage();
try {
await page.goto(options.url, {
timeout: options.timeout,
waitUntil: 'domcontentloaded',
});
await fillCredentials(
page,
options.timeout,
options.username,
options.password,
);
await passSliderCaptcha(page, options.timeout);
const loginResponse = await clickLogin(page, options.timeout);
await assertLoginResponse(loginResponse);
await waitForLoggedIn(page, options.timeout);
const targetUrl = resolveTargetUrl(options.url, options.targetUrl);
if (targetUrl !== page.url()) {
await page.goto(targetUrl, {
timeout: options.timeout,
waitUntil: 'domcontentloaded',
});
}
await page.screenshot({ fullPage: true, path: options.screenshotPath });
await context.storageState({ path: options.storageStatePath });
console.log(
JSON.stringify(
{
ok: true,
screenshotPath: options.screenshotPath,
storageStatePath: options.storageStatePath,
url: page.url(),
username: options.username,
},
null,
2,
),
);
} catch (error) {
const failedScreenshot = options.screenshotPath.replace(
/(\.\w+)?$/,
'.failed.png',
);
await page
.screenshot({ fullPage: true, path: failedScreenshot })
.catch(() => undefined);
throw new Error(
`Admin 登录脚本失败,截图已保存到 ${failedScreenshot}: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});