08ea5fbe98
添加用户管理视图、API和状态管理文件
476 lines
13 KiB
TypeScript
476 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|