feat: 固化Admin登录冒烟脚本

This commit is contained in:
sunlei 2026-05-30 20:52:33 +08:00
parent bc299d2021
commit 84309e9c9a
4 changed files with 405 additions and 1 deletions

View File

@ -24,6 +24,7 @@ pnpm install
pnpm run typecheck
pnpm run self-test
pnpm run cleanup-history -- --dry-run
pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
```
如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use <version>` 切换到目标版本;不要先扫盘找 `node.exe` 路径。
@ -69,6 +70,12 @@ pnpm run cleanup-history -- --dry-run
| `kt_append_task_record` | 预览或写入 `TASKS.md` 改动记录 |
| `kt_commit_checklist` | 生成提交前检查清单并校验 commit message |
## 可复用脚本
| 脚本 | 用途 |
| --- | --- |
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
## 项目别名
| 别名 | 路径 |
@ -94,6 +101,7 @@ pnpm run cleanup-history -- --dry-run
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
- 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。
- 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。
- Admin 页面测试前可先执行 `pnpm run admin-login -- --url <Admin登录页>` 固化登录态,输出的 `storageState` 可作为后续 Playwright 用例前置状态。
- 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。
- 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。
- 每轮测试/验证结束后调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --keep=3`,让历史产物只保留最近 3 轮。

View File

@ -5,6 +5,7 @@
"type": "module",
"description": "Reusable MCP workflow tools for the KT workspace.",
"scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
"self-test": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --self-test",
"start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts",

View File

@ -0,0 +1,395 @@
#!/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 URLhash
* @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);
});

View File

@ -13,5 +13,5 @@
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
"include": ["scripts/**/*.ts", "src/**/*.ts"]
}