feat: 固化Admin登录冒烟脚本
This commit is contained in:
parent
bc299d2021
commit
84309e9c9a
@ -24,6 +24,7 @@ pnpm install
|
|||||||
pnpm run typecheck
|
pnpm run typecheck
|
||||||
pnpm run self-test
|
pnpm run self-test
|
||||||
pnpm run cleanup-history -- --dry-run
|
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` 路径。
|
如果本机 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_append_task_record` | 预览或写入 `TASKS.md` 改动记录 |
|
||||||
| `kt_commit_checklist` | 生成提交前检查清单并校验 commit message |
|
| `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_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
|
||||||
- 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。
|
- 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。
|
||||||
- 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。
|
- 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。
|
||||||
|
- Admin 页面测试前可先执行 `pnpm run admin-login -- --url <Admin登录页>` 固化登录态,输出的 `storageState` 可作为后续 Playwright 用例前置状态。
|
||||||
- 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。
|
- 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。
|
||||||
- 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。
|
- 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。
|
||||||
- 每轮测试/验证结束后调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --keep=3`,让历史产物只保留最近 3 轮。
|
- 每轮测试/验证结束后调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --keep=3`,让历史产物只保留最近 3 轮。
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Reusable MCP workflow tools for the KT workspace.",
|
"description": "Reusable MCP workflow tools for the KT workspace.",
|
||||||
"scripts": {
|
"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",
|
"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",
|
"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",
|
"start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts",
|
||||||
|
|||||||
395
scripts/admin-login-smoke.ts
Normal file
395
scripts/admin-login-smoke.ts
Normal 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 登录后目标,可以是绝对 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);
|
||||||
|
});
|
||||||
@ -13,5 +13,5 @@
|
|||||||
"target": "ES2024",
|
"target": "ES2024",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["scripts/**/*.ts", "src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user