feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
TestResult,
|
||||
TestModule,
|
||||
TestSuite,
|
||||
TestCase,
|
||||
TestStatus,
|
||||
TestResultBuilder,
|
||||
TestModuleBuilder,
|
||||
ErrorType
|
||||
} from '../models/test-result';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface PlaywrightTest {
|
||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
duration: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
annotations: Array<{ type: string; description?: string }>;
|
||||
results?: Array<{
|
||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
duration: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
stdout?: Array<{ text: string }>;
|
||||
stderr?: Array<{ text: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PlaywrightSpec {
|
||||
title: string;
|
||||
tests: PlaywrightTest[];
|
||||
status: 'expected' | 'unexpected' | 'skipped' | 'flaky';
|
||||
file?: string;
|
||||
line?: number;
|
||||
tags?: string[];
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
interface PlaywrightSuite {
|
||||
title: string;
|
||||
suites: PlaywrightSuite[];
|
||||
specs: PlaywrightSpec[];
|
||||
}
|
||||
|
||||
interface PlaywrightProject {
|
||||
name: string;
|
||||
suites: PlaywrightSuite[];
|
||||
}
|
||||
|
||||
interface PlaywrightReport {
|
||||
config?: {
|
||||
projects?: PlaywrightProject[];
|
||||
};
|
||||
suites?: PlaywrightSuite[];
|
||||
projects?: PlaywrightProject[];
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
stack?: string;
|
||||
location?: {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
}>;
|
||||
stats: {
|
||||
startTime?: string;
|
||||
duration: number;
|
||||
expected: number;
|
||||
unexpected: number;
|
||||
skipped: number;
|
||||
flaky?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestExecutor {
|
||||
private testRoot: string;
|
||||
private resultsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.testRoot = process.cwd();
|
||||
this.resultsDir = path.join(this.testRoot, 'test-results');
|
||||
this.ensureResultsDir();
|
||||
}
|
||||
|
||||
private ensureResultsDir(): void {
|
||||
if (!fs.existsSync(this.resultsDir)) {
|
||||
fs.mkdirSync(this.resultsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async executeAll(): Promise<TestResult> {
|
||||
logger.section('执行全量测试');
|
||||
|
||||
const builder = new TestResultBuilder();
|
||||
builder.setStartTime(new Date());
|
||||
|
||||
const modules: Array<'api' | 'admin' | 'uniapp'> = ['api', 'admin', 'uniapp'];
|
||||
|
||||
for (const moduleName of modules) {
|
||||
logger.info(`开始执行 ${moduleName} 模块测试...`);
|
||||
const module = await this.executeModule(moduleName);
|
||||
builder.addModule(module);
|
||||
}
|
||||
|
||||
builder.setEndTime(new Date());
|
||||
const result = builder.build();
|
||||
|
||||
logger.info('全量测试执行完成', {
|
||||
totalTests: result.totalTests,
|
||||
passed: result.passedTests,
|
||||
failed: result.failedTests,
|
||||
passRate: `${result.passRate.toFixed(2)}%`
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async executeModule(moduleName: 'api' | 'admin' | 'uniapp'): Promise<TestModule> {
|
||||
const builder = new TestModuleBuilder(moduleName);
|
||||
builder.setStartTime(new Date());
|
||||
|
||||
const testDir = this.getTestDir(moduleName);
|
||||
|
||||
if (!fs.existsSync(testDir)) {
|
||||
logger.warn(`${moduleName} 测试目录不存在: ${testDir}`);
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
const testFiles = this.findTestFiles(testDir);
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
logger.warn(`${moduleName} 模块没有找到测试文件`);
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
logger.info(`找到 ${testFiles.length} 个测试文件`);
|
||||
|
||||
const reportPath = path.join(this.resultsDir, `${moduleName}-report.json`);
|
||||
await this.runPlaywrightTests(moduleName, testDir, reportPath);
|
||||
|
||||
const suites = await this.parseTestResults(reportPath, moduleName);
|
||||
for (const suite of suites) {
|
||||
builder.addSuite(suite);
|
||||
}
|
||||
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private getTestDir(moduleName: 'api' | 'admin' | 'uniapp'): string {
|
||||
const baseDir = path.join(this.testRoot, 'e2e');
|
||||
switch (moduleName) {
|
||||
case 'api':
|
||||
return path.join(baseDir, 'config');
|
||||
case 'admin':
|
||||
return path.join(baseDir, 'admin');
|
||||
case 'uniapp':
|
||||
return path.join(baseDir, 'uniapp');
|
||||
default:
|
||||
return baseDir;
|
||||
}
|
||||
}
|
||||
|
||||
private findTestFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
const scanDir = (currentDir: string) => {
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.spec.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanDir(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
private async runPlaywrightTests(
|
||||
moduleName: string,
|
||||
testDir: string,
|
||||
reportPath: string
|
||||
): Promise<void> {
|
||||
logger.info(`运行 Playwright 测试: ${moduleName}`);
|
||||
|
||||
const command = [
|
||||
'npx playwright test',
|
||||
testDir,
|
||||
'--reporter=json',
|
||||
'--workers=2',
|
||||
'--retries=1'
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: this.testRoot,
|
||||
maxBuffer: 1024 * 1024 * 50
|
||||
});
|
||||
|
||||
if (stdout) {
|
||||
const jsonContent = this.extractJsonFromOutput(stdout);
|
||||
if (jsonContent) {
|
||||
fs.writeFileSync(reportPath, jsonContent, 'utf-8');
|
||||
logger.debug(`Playwright 报告已保存: ${reportPath}`);
|
||||
}
|
||||
}
|
||||
if (stderr) {
|
||||
logger.debug(`Playwright 错误输出: ${stderr}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const execError = error as { stdout?: string; stderr?: string; message?: string };
|
||||
if (execError.stdout) {
|
||||
const jsonContent = this.extractJsonFromOutput(execError.stdout);
|
||||
if (jsonContent) {
|
||||
fs.writeFileSync(reportPath, jsonContent, 'utf-8');
|
||||
logger.debug(`Playwright 报告已保存: ${reportPath}`);
|
||||
}
|
||||
}
|
||||
if (execError.stderr) {
|
||||
logger.debug(`Playwright 错误输出: ${execError.stderr}`);
|
||||
}
|
||||
logger.warn(`${moduleName} 测试执行有失败用例`);
|
||||
}
|
||||
}
|
||||
|
||||
private extractJsonFromOutput(output: string): string | null {
|
||||
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
||||
const cleanOutput = output.replace(ansiRegex, '');
|
||||
|
||||
const lines = cleanOutput.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonStart = cleanOutput.indexOf('{');
|
||||
if (jsonStart !== -1) {
|
||||
try {
|
||||
const jsonStr = cleanOutput.substring(jsonStart);
|
||||
JSON.parse(jsonStr);
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async parseTestResults(
|
||||
reportPath: string,
|
||||
moduleName: 'api' | 'admin' | 'uniapp'
|
||||
): Promise<TestSuite[]> {
|
||||
const suites: TestSuite[] = [];
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
logger.warn(`测试报告不存在: ${reportPath}`);
|
||||
return suites;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(reportPath, 'utf-8');
|
||||
const report = JSON.parse(content) as PlaywrightReport;
|
||||
|
||||
const allSuites = this.getAllSuites(report);
|
||||
const testCases = this.extractTestCases(allSuites, moduleName);
|
||||
|
||||
if (report.errors && report.errors.length > 0) {
|
||||
for (const error of report.errors) {
|
||||
const errorTestCase: TestCase = {
|
||||
id: `${moduleName}-error-${Date.now()}`,
|
||||
name: '测试加载错误',
|
||||
suite: '全局错误',
|
||||
module: moduleName,
|
||||
status: TestStatus.FAILED,
|
||||
duration: 0,
|
||||
startTime: new Date(),
|
||||
steps: [],
|
||||
retries: 0,
|
||||
maxRetries: 0,
|
||||
tags: ['error'],
|
||||
priority: 'high',
|
||||
error: {
|
||||
type: ErrorType.ENVIRONMENT_ERROR,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date()
|
||||
}
|
||||
};
|
||||
testCases.push(errorTestCase);
|
||||
}
|
||||
}
|
||||
|
||||
const suite: TestSuite = {
|
||||
id: `${moduleName}-suite`,
|
||||
name: `${moduleName} 测试套件`,
|
||||
module: moduleName,
|
||||
tests: testCases,
|
||||
status: testCases.some(t => t.status === TestStatus.FAILED)
|
||||
? TestStatus.FAILED
|
||||
: TestStatus.PASSED,
|
||||
duration: testCases.reduce((sum, t) => sum + t.duration, 0),
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
passRate: testCases.length > 0
|
||||
? (testCases.filter(t => t.status === TestStatus.PASSED).length / testCases.length) * 100
|
||||
: 0
|
||||
};
|
||||
|
||||
suites.push(suite);
|
||||
} catch (error) {
|
||||
logger.error(`解析测试报告失败: ${reportPath}`, error);
|
||||
}
|
||||
|
||||
return suites;
|
||||
}
|
||||
|
||||
private getAllSuites(report: PlaywrightReport): PlaywrightSuite[] {
|
||||
const allSuites: PlaywrightSuite[] = [];
|
||||
|
||||
const collectSuites = (suites: PlaywrightSuite[]) => {
|
||||
for (const suite of suites) {
|
||||
allSuites.push(suite);
|
||||
if (suite.suites && suite.suites.length > 0) {
|
||||
collectSuites(suite.suites);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (report.projects) {
|
||||
for (const project of report.projects) {
|
||||
collectSuites(project.suites);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.suites) {
|
||||
collectSuites(report.suites);
|
||||
}
|
||||
|
||||
return allSuites;
|
||||
}
|
||||
|
||||
private extractTestCases(
|
||||
suites: PlaywrightSuite[],
|
||||
moduleName: 'api' | 'admin' | 'uniapp'
|
||||
): TestCase[] {
|
||||
const cases: TestCase[] = [];
|
||||
let idCounter = 1;
|
||||
|
||||
for (const suite of suites) {
|
||||
for (const spec of suite.specs) {
|
||||
for (const test of spec.tests) {
|
||||
const testResult = test.results?.[0] || test;
|
||||
const testCase: TestCase = {
|
||||
id: `${moduleName}-test-${idCounter++}`,
|
||||
name: spec.title,
|
||||
suite: suite.title,
|
||||
module: moduleName,
|
||||
status: this.mapStatus(testResult.status),
|
||||
duration: testResult.duration || 0,
|
||||
startTime: new Date(),
|
||||
steps: [],
|
||||
retries: 0,
|
||||
maxRetries: 1,
|
||||
tags: spec.tags || test.annotations?.map(a => a.type) || [],
|
||||
priority: 'medium'
|
||||
};
|
||||
|
||||
if (testResult.error) {
|
||||
testCase.error = {
|
||||
type: this.detectErrorType(testResult.error.message),
|
||||
message: testResult.error.message,
|
||||
stack: testResult.error.stack,
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
cases.push(testCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
private mapStatus(status: string): TestStatus {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return TestStatus.PASSED;
|
||||
case 'failed':
|
||||
return TestStatus.FAILED;
|
||||
case 'timedOut':
|
||||
return TestStatus.FAILED;
|
||||
case 'skipped':
|
||||
return TestStatus.SKIPPED;
|
||||
default:
|
||||
return TestStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
private detectErrorType(message: string): ErrorType {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('timeout') || lowerMessage.includes('timed out')) {
|
||||
return ErrorType.TIMEOUT;
|
||||
}
|
||||
if (lowerMessage.includes('not found') || lowerMessage.includes('no element') || lowerMessage.includes('selector')) {
|
||||
return ErrorType.ELEMENT_NOT_FOUND;
|
||||
}
|
||||
if (lowerMessage.includes('api') || lowerMessage.includes('request failed') || lowerMessage.includes('status code')) {
|
||||
return ErrorType.API_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('assertion') || lowerMessage.includes('expect') || lowerMessage.includes('assert')) {
|
||||
return ErrorType.ASSERTION_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('network') || lowerMessage.includes('connection') || lowerMessage.includes('econnrefused')) {
|
||||
return ErrorType.NETWORK_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('auth') || lowerMessage.includes('unauthorized') || lowerMessage.includes('forbidden')) {
|
||||
return ErrorType.AUTH_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('data') || lowerMessage.includes('json') || lowerMessage.includes('parse')) {
|
||||
return ErrorType.DATA_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('environment') || lowerMessage.includes('config') || lowerMessage.includes('setup')) {
|
||||
return ErrorType.ENVIRONMENT_ERROR;
|
||||
}
|
||||
|
||||
return ErrorType.UNKNOWN;
|
||||
}
|
||||
|
||||
async runSpecificTest(testPath: string): Promise<TestCase | null> {
|
||||
logger.info(`运行单个测试: ${testPath}`);
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`npx playwright test ${testPath} --reporter=json`,
|
||||
{ cwd: this.testRoot, maxBuffer: 1024 * 1024 * 10 }
|
||||
);
|
||||
|
||||
const report = JSON.parse(stdout) as PlaywrightReport;
|
||||
const allSuites = this.getAllSuites(report);
|
||||
const cases = this.extractTestCases(allSuites, 'api');
|
||||
return cases[0] || null;
|
||||
} catch (error) {
|
||||
logger.error(`运行测试失败: ${testPath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user