@be-link/smart-test 1.0.1-beta.2 → 1.0.1-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/smart-test.js +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/record.d.ts +6 -0
- package/dist/cli/commands/record.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1484 -15
- package/dist/config/config-schema.d.ts +1 -1
- package/dist/config/config-schema.d.ts.map +1 -1
- package/dist/core/ai-assistant.d.ts.map +1 -1
- package/dist/index.esm.js +2 -8
- package/dist/index.js +2 -8
- package/dist/recorder/har-converter.d.ts +51 -0
- package/dist/recorder/har-converter.d.ts.map +1 -0
- package/dist/recorder/interactive-guide.d.ts +67 -0
- package/dist/recorder/interactive-guide.d.ts.map +1 -0
- package/dist/recorder/network-recorder.d.ts +44 -0
- package/dist/recorder/network-recorder.d.ts.map +1 -0
- package/dist/recorder/pom-generator.d.ts +68 -0
- package/dist/recorder/pom-generator.d.ts.map +1 -0
- package/dist/recorder/test-generator.d.ts +39 -0
- package/dist/recorder/test-generator.d.ts.map +1 -0
- package/dist/recorder/types.d.ts +130 -0
- package/dist/recorder/types.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as path from 'path';
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import ora from 'ora';
|
|
7
7
|
import { cosmiconfig } from 'cosmiconfig';
|
|
8
|
+
import { chromium, webkit, firefox } from '@playwright/test';
|
|
9
|
+
import OpenAI from 'openai';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* 日志级别
|
|
@@ -319,11 +321,11 @@ async function initCommand() {
|
|
|
319
321
|
name: 'naming',
|
|
320
322
|
message: '文件命名风格:',
|
|
321
323
|
choices: [
|
|
322
|
-
{ name: '
|
|
324
|
+
{ name: 'PascalCase (推荐)', value: 'PascalCase' },
|
|
323
325
|
{ name: 'camelCase', value: 'camelCase' },
|
|
324
|
-
{ name: '
|
|
326
|
+
{ name: 'kebab-case', value: 'kebab-case' },
|
|
325
327
|
],
|
|
326
|
-
default: '
|
|
328
|
+
default: 'PascalCase',
|
|
327
329
|
},
|
|
328
330
|
]);
|
|
329
331
|
// 构建配置对象
|
|
@@ -382,7 +384,7 @@ const DEFAULT_CONFIG = {
|
|
|
382
384
|
output: {
|
|
383
385
|
testsDir: './tests/e2e',
|
|
384
386
|
helpersDir: './tests/helpers',
|
|
385
|
-
naming: '
|
|
387
|
+
naming: 'PascalCase',
|
|
386
388
|
},
|
|
387
389
|
templates: {
|
|
388
390
|
mock: 'default',
|
|
@@ -507,6 +509,1462 @@ var configLoader$1 = /*#__PURE__*/Object.freeze({
|
|
|
507
509
|
configLoader: configLoader
|
|
508
510
|
});
|
|
509
511
|
|
|
512
|
+
/**
|
|
513
|
+
* 网络请求录制器
|
|
514
|
+
*/
|
|
515
|
+
class NetworkRecorder {
|
|
516
|
+
constructor() {
|
|
517
|
+
this.browser = null;
|
|
518
|
+
this.context = null;
|
|
519
|
+
this.page = null;
|
|
520
|
+
this.requests = [];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* 启动录制
|
|
524
|
+
*/
|
|
525
|
+
async start(options) {
|
|
526
|
+
logger.info(`启动浏览器: ${options.browser || 'chromium'}`);
|
|
527
|
+
// 启动浏览器
|
|
528
|
+
const browserType = options.browser || 'chromium';
|
|
529
|
+
switch (browserType) {
|
|
530
|
+
case 'firefox':
|
|
531
|
+
this.browser = await firefox.launch({ headless: false });
|
|
532
|
+
break;
|
|
533
|
+
case 'webkit':
|
|
534
|
+
this.browser = await webkit.launch({ headless: false });
|
|
535
|
+
break;
|
|
536
|
+
default:
|
|
537
|
+
this.browser = await chromium.launch({ headless: false });
|
|
538
|
+
}
|
|
539
|
+
// 创建浏览器上下文
|
|
540
|
+
this.context = await this.browser.newContext({
|
|
541
|
+
viewport: options.viewport || { width: 375, height: 667 },
|
|
542
|
+
locale: 'zh-CN',
|
|
543
|
+
});
|
|
544
|
+
// 创建页面
|
|
545
|
+
this.page = await this.context.newPage();
|
|
546
|
+
// 开始录制网络请求
|
|
547
|
+
this.startRecording();
|
|
548
|
+
logger.success('浏览器已启动');
|
|
549
|
+
logger.info(`访问页面: ${options.url}`);
|
|
550
|
+
// 访问页面
|
|
551
|
+
await this.page.goto(options.url, {
|
|
552
|
+
waitUntil: 'networkidle',
|
|
553
|
+
timeout: 30000,
|
|
554
|
+
});
|
|
555
|
+
logger.success('页面已加载');
|
|
556
|
+
return this.page;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 开始录制网络请求
|
|
560
|
+
*/
|
|
561
|
+
startRecording() {
|
|
562
|
+
if (!this.page || !this.context) {
|
|
563
|
+
throw new Error('页面或浏览器上下文未初始化');
|
|
564
|
+
}
|
|
565
|
+
this.requests = [];
|
|
566
|
+
// 拦截请求
|
|
567
|
+
this.page.on('request', (request) => {
|
|
568
|
+
// 过滤静态资源
|
|
569
|
+
const url = request.url();
|
|
570
|
+
if (this.shouldRecordRequest(url)) {
|
|
571
|
+
const networkRequest = {
|
|
572
|
+
url: request.url(),
|
|
573
|
+
method: request.method(),
|
|
574
|
+
headers: request.headers(),
|
|
575
|
+
postData: request.postData() || undefined,
|
|
576
|
+
timestamp: Date.now(),
|
|
577
|
+
};
|
|
578
|
+
this.requests.push(networkRequest);
|
|
579
|
+
logger.debug(`捕获请求: ${request.method()} ${url}`);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
// 拦截响应
|
|
583
|
+
this.page.on('response', async (response) => {
|
|
584
|
+
const url = response.url();
|
|
585
|
+
if (this.shouldRecordRequest(url)) {
|
|
586
|
+
const matchedRequest = this.requests.find((req) => req.url === url && req.method === response.request().method() && !req.response);
|
|
587
|
+
if (matchedRequest) {
|
|
588
|
+
try {
|
|
589
|
+
const body = await response.text();
|
|
590
|
+
const networkResponse = {
|
|
591
|
+
status: response.status(),
|
|
592
|
+
headers: response.headers(),
|
|
593
|
+
body,
|
|
594
|
+
timestamp: Date.now(),
|
|
595
|
+
};
|
|
596
|
+
matchedRequest.response = networkResponse;
|
|
597
|
+
logger.debug(`捕获响应: ${response.status()} ${url}`);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
logger.warn(`无法读取响应体: ${url}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
logger.success('网络请求录制已启动');
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* 判断是否应该录制该请求
|
|
609
|
+
*/
|
|
610
|
+
shouldRecordRequest(url) {
|
|
611
|
+
// 过滤掉静态资源
|
|
612
|
+
const staticExtensions = [
|
|
613
|
+
'.png',
|
|
614
|
+
'.jpg',
|
|
615
|
+
'.jpeg',
|
|
616
|
+
'.gif',
|
|
617
|
+
'.svg',
|
|
618
|
+
'.ico',
|
|
619
|
+
'.css',
|
|
620
|
+
'.js',
|
|
621
|
+
'.woff',
|
|
622
|
+
'.woff2',
|
|
623
|
+
'.ttf',
|
|
624
|
+
];
|
|
625
|
+
// 过滤掉 data: 和 blob: URL
|
|
626
|
+
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
// 检查是否是静态资源
|
|
630
|
+
return !staticExtensions.some((ext) => url.endsWith(ext));
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* 停止录制
|
|
634
|
+
*/
|
|
635
|
+
async stop() {
|
|
636
|
+
logger.info('停止录制...');
|
|
637
|
+
// 等待一下,确保最后的请求都被捕获
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
639
|
+
// 关闭浏览器
|
|
640
|
+
if (this.browser) {
|
|
641
|
+
await this.browser.close();
|
|
642
|
+
this.browser = null;
|
|
643
|
+
}
|
|
644
|
+
logger.success(`录制完成,共捕获 ${this.requests.length} 个请求`);
|
|
645
|
+
return this.requests;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 获取当前录制的请求
|
|
649
|
+
*/
|
|
650
|
+
getRequests() {
|
|
651
|
+
return [...this.requests];
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 获取页面实例
|
|
655
|
+
*/
|
|
656
|
+
getPage() {
|
|
657
|
+
return this.page;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* 等待用户操作
|
|
661
|
+
*/
|
|
662
|
+
async waitForUserInteraction(timeout = 60) {
|
|
663
|
+
logger.prompt(`请在浏览器中进行操作,完成后按 Ctrl+C 或等待 ${timeout} 秒后自动停止`);
|
|
664
|
+
// 监听 Ctrl+C
|
|
665
|
+
const controller = new AbortController();
|
|
666
|
+
const signal = controller.signal;
|
|
667
|
+
return new Promise((resolve) => {
|
|
668
|
+
const handler = () => {
|
|
669
|
+
logger.info('收到停止信号');
|
|
670
|
+
resolve();
|
|
671
|
+
};
|
|
672
|
+
process.on('SIGINT', handler);
|
|
673
|
+
signal.addEventListener('abort', handler);
|
|
674
|
+
// 超时自动停止
|
|
675
|
+
const timeoutId = setTimeout(() => {
|
|
676
|
+
logger.info('录制超时,自动停止');
|
|
677
|
+
controller.abort();
|
|
678
|
+
resolve();
|
|
679
|
+
}, timeout * 1000);
|
|
680
|
+
// 清理
|
|
681
|
+
signal.addEventListener('abort', () => {
|
|
682
|
+
clearTimeout(timeoutId);
|
|
683
|
+
process.removeListener('SIGINT', handler);
|
|
684
|
+
}, { once: true });
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* 显示录制统计
|
|
689
|
+
*/
|
|
690
|
+
displayStats() {
|
|
691
|
+
const stats = {
|
|
692
|
+
total: this.requests.length,
|
|
693
|
+
withResponse: this.requests.filter((r) => r.response).length,
|
|
694
|
+
withoutResponse: this.requests.filter((r) => !r.response).length,
|
|
695
|
+
byMethod: {},
|
|
696
|
+
};
|
|
697
|
+
this.requests.forEach((req) => {
|
|
698
|
+
stats.byMethod[req.method] = (stats.byMethod[req.method] || 0) + 1;
|
|
699
|
+
});
|
|
700
|
+
logger.newLine();
|
|
701
|
+
logger.title('录制统计');
|
|
702
|
+
logger.info(`总请求数: ${stats.total}`);
|
|
703
|
+
logger.info(`有响应: ${stats.withResponse}`);
|
|
704
|
+
logger.info(`无响应: ${stats.withoutResponse}`);
|
|
705
|
+
logger.newLine();
|
|
706
|
+
logger.info('按方法统计:');
|
|
707
|
+
Object.entries(stats.byMethod).forEach(([method, count]) => {
|
|
708
|
+
logger.info(` ${method}: ${count}`);
|
|
709
|
+
});
|
|
710
|
+
logger.newLine();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* HAR 转换器
|
|
716
|
+
*/
|
|
717
|
+
class HARConverter {
|
|
718
|
+
/**
|
|
719
|
+
* 将网络请求转换为 HAR 格式
|
|
720
|
+
*/
|
|
721
|
+
toHAR(requests) {
|
|
722
|
+
const entries = requests
|
|
723
|
+
.filter((req) => req.response) // 只包含有响应的请求
|
|
724
|
+
.map((req) => this.requestToHAREntry(req));
|
|
725
|
+
return {
|
|
726
|
+
log: {
|
|
727
|
+
version: '1.2',
|
|
728
|
+
creator: {
|
|
729
|
+
name: '@be-link/smart-test',
|
|
730
|
+
version: '1.0.0',
|
|
731
|
+
},
|
|
732
|
+
entries,
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* 将单个请求转换为 HAR 条目
|
|
738
|
+
*/
|
|
739
|
+
requestToHAREntry(request) {
|
|
740
|
+
const response = request.response;
|
|
741
|
+
const startTime = new Date(request.timestamp).toISOString();
|
|
742
|
+
const duration = response.timestamp - request.timestamp;
|
|
743
|
+
return {
|
|
744
|
+
startedDateTime: startTime,
|
|
745
|
+
time: duration,
|
|
746
|
+
request: {
|
|
747
|
+
method: request.method,
|
|
748
|
+
url: request.url,
|
|
749
|
+
headers: Object.entries(request.headers).map(([name, value]) => ({ name, value })),
|
|
750
|
+
postData: request.postData
|
|
751
|
+
? {
|
|
752
|
+
mimeType: request.headers['content-type'] || 'application/json',
|
|
753
|
+
text: request.postData,
|
|
754
|
+
}
|
|
755
|
+
: undefined,
|
|
756
|
+
},
|
|
757
|
+
response: {
|
|
758
|
+
status: response.status,
|
|
759
|
+
headers: Object.entries(response.headers).map(([name, value]) => ({ name, value })),
|
|
760
|
+
content: {
|
|
761
|
+
mimeType: response.headers['content-type'] || 'application/json',
|
|
762
|
+
text: response.body,
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* 保存 HAR 文件
|
|
769
|
+
*/
|
|
770
|
+
async saveHAR(requests, outputPath) {
|
|
771
|
+
const har = this.toHAR(requests);
|
|
772
|
+
const harPath = path.join(outputPath, 'recorded.har');
|
|
773
|
+
await fs.mkdir(outputPath, { recursive: true });
|
|
774
|
+
await fs.writeFile(harPath, JSON.stringify(har, null, 2), 'utf-8');
|
|
775
|
+
logger.success(`HAR 文件已保存: ${harPath}`);
|
|
776
|
+
return harPath;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* 从 HAR 生成 Mock 数据对象
|
|
780
|
+
*/
|
|
781
|
+
generateMockData(requests) {
|
|
782
|
+
const mockData = {};
|
|
783
|
+
requests
|
|
784
|
+
.filter((req) => req.response)
|
|
785
|
+
.forEach((req) => {
|
|
786
|
+
// 提取 API 路径作为 key
|
|
787
|
+
const apiPath = this.extractApiPath(req.url);
|
|
788
|
+
const key = this.generateMockKey(apiPath, req.method);
|
|
789
|
+
// 获取 content-type
|
|
790
|
+
const contentType = req.response.headers['content-type'] || 'text/plain';
|
|
791
|
+
const isJson = contentType.includes('application/json');
|
|
792
|
+
try {
|
|
793
|
+
// 尝试解析 JSON 响应
|
|
794
|
+
const responseBody = isJson ? JSON.parse(req.response.body) : req.response.body;
|
|
795
|
+
mockData[key] = {
|
|
796
|
+
url: apiPath,
|
|
797
|
+
method: req.method,
|
|
798
|
+
status: req.response.status,
|
|
799
|
+
contentType,
|
|
800
|
+
isJson,
|
|
801
|
+
response: responseBody,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
// JSON 解析失败,直接存储原始文本
|
|
806
|
+
mockData[key] = {
|
|
807
|
+
url: apiPath,
|
|
808
|
+
method: req.method,
|
|
809
|
+
status: req.response.status,
|
|
810
|
+
contentType,
|
|
811
|
+
isJson: false,
|
|
812
|
+
response: req.response.body,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
return mockData;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* 提取 API 路径(去除域名和查询参数)
|
|
820
|
+
*/
|
|
821
|
+
extractApiPath(url) {
|
|
822
|
+
try {
|
|
823
|
+
const urlObj = new URL(url);
|
|
824
|
+
return urlObj.pathname;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return url;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* 生成 Mock 数据的 key
|
|
832
|
+
*/
|
|
833
|
+
generateMockKey(apiPath, method) {
|
|
834
|
+
// 将路径转换为驼峰命名
|
|
835
|
+
// /api/user/info -> apiUserInfo
|
|
836
|
+
const parts = apiPath
|
|
837
|
+
.split('/')
|
|
838
|
+
.filter((p) => p)
|
|
839
|
+
.map((p, index) => (index === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)));
|
|
840
|
+
return `${method.toLowerCase()}${parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')}`;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* 生成 Playwright Mock 代码
|
|
844
|
+
*/
|
|
845
|
+
generateMockCode(requests, pageName) {
|
|
846
|
+
const mockData = this.generateMockData(requests);
|
|
847
|
+
// 生成 TypeScript 代码
|
|
848
|
+
let code = `import type { Page } from '@playwright/test';\n\n`;
|
|
849
|
+
code += `// Mock 数据常量\n`;
|
|
850
|
+
// 生成每个 Mock 数据常量
|
|
851
|
+
Object.entries(mockData).forEach(([key, data]) => {
|
|
852
|
+
code += `const ${key}Data = ${JSON.stringify(data.response, null, 2)};\n\n`;
|
|
853
|
+
});
|
|
854
|
+
// 生成 Mock 函数
|
|
855
|
+
code += `/**\n`;
|
|
856
|
+
code += ` * Mock ${pageName} 页面的 API\n`;
|
|
857
|
+
code += ` */\n`;
|
|
858
|
+
code += `export async function mock${this.capitalize(pageName)}Apis(page: Page): Promise<void> {\n`;
|
|
859
|
+
// 为每个请求生成 route
|
|
860
|
+
Object.entries(mockData).forEach(([key, data]) => {
|
|
861
|
+
const urlPattern = this.generateUrlPattern(data.url);
|
|
862
|
+
code += ` // ${data.method} ${data.url}\n`;
|
|
863
|
+
code += ` await page.route('${urlPattern}', async (route) => {\n`;
|
|
864
|
+
code += ` await route.fulfill({\n`;
|
|
865
|
+
code += ` status: ${data.status},\n`;
|
|
866
|
+
code += ` headers: { 'content-type': '${data.contentType}' },\n`;
|
|
867
|
+
// 根据响应类型决定如何处理 body
|
|
868
|
+
if (data.isJson) {
|
|
869
|
+
code += ` body: JSON.stringify(${key}Data),\n`;
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
code += ` body: ${key}Data,\n`;
|
|
873
|
+
}
|
|
874
|
+
code += ` });\n`;
|
|
875
|
+
code += ` });\n\n`;
|
|
876
|
+
});
|
|
877
|
+
code += `}\n`;
|
|
878
|
+
return code;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* 生成 URL 匹配模式
|
|
882
|
+
*/
|
|
883
|
+
generateUrlPattern(apiPath) {
|
|
884
|
+
// 将 API 路径转换为通配符模式
|
|
885
|
+
// 精确匹配路径,但允许不同的域名和查询参数
|
|
886
|
+
// /api/user/info -> **/api/user/info
|
|
887
|
+
// / -> **/
|
|
888
|
+
// 如果路径以斜杠结尾,保持原样;否则在末尾添加可选的查询参数匹配
|
|
889
|
+
if (apiPath === '/' || apiPath.endsWith('/')) {
|
|
890
|
+
return `**${apiPath}`;
|
|
891
|
+
}
|
|
892
|
+
// 对于其他路径,精确匹配路径部分,但允许查询参数
|
|
893
|
+
return `**${apiPath}?(|?*)`;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* 首字母大写
|
|
897
|
+
*/
|
|
898
|
+
capitalize(str) {
|
|
899
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 转换为 PascalCase
|
|
903
|
+
*/
|
|
904
|
+
toPascalCase(str) {
|
|
905
|
+
return str
|
|
906
|
+
.split(/[-_\s]+/)
|
|
907
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
908
|
+
.join('');
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* 保存 Mock 代码
|
|
912
|
+
*/
|
|
913
|
+
async saveMockCode(requests, outputPath, pageName) {
|
|
914
|
+
const mockCode = this.generateMockCode(requests, pageName);
|
|
915
|
+
const fileName = `Mock${this.toPascalCase(pageName)}.ts`;
|
|
916
|
+
const mockPath = path.join(outputPath, fileName);
|
|
917
|
+
await fs.mkdir(outputPath, { recursive: true });
|
|
918
|
+
await fs.writeFile(mockPath, mockCode, 'utf-8');
|
|
919
|
+
logger.success(`Mock 代码已保存: ${mockPath}`);
|
|
920
|
+
return mockPath;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* AI 测试用例生成器
|
|
926
|
+
*/
|
|
927
|
+
class TestGenerator {
|
|
928
|
+
constructor(config) {
|
|
929
|
+
this.config = config;
|
|
930
|
+
this.openai = null;
|
|
931
|
+
// 初始化 OpenAI 客户端
|
|
932
|
+
if (config.ai?.apiKey) {
|
|
933
|
+
this.openai = new OpenAI({
|
|
934
|
+
apiKey: config.ai.apiKey,
|
|
935
|
+
baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
936
|
+
timeout: config.ai.timeout || 60000,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* 从录制数据生成测试用例
|
|
942
|
+
*/
|
|
943
|
+
async generateTest(url, pageName, requests, mockFileName) {
|
|
944
|
+
if (!this.openai) {
|
|
945
|
+
logger.warn('未配置 AI API Key,跳过测试用例生成');
|
|
946
|
+
return this.generateBasicTest(url, pageName, mockFileName);
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
logger.debug('准备 AI 提示词...');
|
|
950
|
+
// 准备 AI 提示词
|
|
951
|
+
const prompt = this.buildPrompt(url, pageName, requests, mockFileName);
|
|
952
|
+
logger.debug('调用 AI 生成测试用例...');
|
|
953
|
+
// 调用 AI 生成测试用例
|
|
954
|
+
const response = await this.openai.chat.completions.create({
|
|
955
|
+
model: this.config.ai?.model || 'qwen-vl-plus',
|
|
956
|
+
messages: [
|
|
957
|
+
{
|
|
958
|
+
role: 'system',
|
|
959
|
+
content: '你是一个专业的测试工程师,擅长使用 Playwright 编写 E2E 测试。请根据提供的页面信息和网络请求记录,生成高质量的测试用例代码。',
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
role: 'user',
|
|
963
|
+
content: prompt,
|
|
964
|
+
},
|
|
965
|
+
],
|
|
966
|
+
temperature: 0.3,
|
|
967
|
+
});
|
|
968
|
+
const generatedCode = response.choices[0]?.message?.content || '';
|
|
969
|
+
// 提取代码块
|
|
970
|
+
const codeMatch = generatedCode.match(/```(?:typescript|ts)?\n([\s\S]*?)```/);
|
|
971
|
+
if (codeMatch) {
|
|
972
|
+
return codeMatch[1].trim();
|
|
973
|
+
}
|
|
974
|
+
return generatedCode.trim();
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
logger.warn(`AI 生成测试失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
978
|
+
logger.info('将生成基础测试模板');
|
|
979
|
+
return this.generateBasicTest(url, pageName, mockFileName);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* 构建 AI 提示词
|
|
984
|
+
*/
|
|
985
|
+
buildPrompt(url, pageName, requests, mockFileName) {
|
|
986
|
+
// 统计请求信息
|
|
987
|
+
const requestSummary = this.summarizeRequests(requests);
|
|
988
|
+
return `
|
|
989
|
+
请为以下页面生成 Playwright 测试用例:
|
|
990
|
+
|
|
991
|
+
## 页面信息
|
|
992
|
+
- URL: ${url}
|
|
993
|
+
- 页面名称: ${pageName}
|
|
994
|
+
|
|
995
|
+
## 网络请求统计
|
|
996
|
+
${requestSummary}
|
|
997
|
+
|
|
998
|
+
## 要求
|
|
999
|
+
1. 使用 TypeScript 语法
|
|
1000
|
+
2. 导入必要的函数:
|
|
1001
|
+
- import { test, expect } from '@playwright/test'
|
|
1002
|
+
- import { aiReviewScreenshot } from '@be-link/smart-test'
|
|
1003
|
+
- import { mock${this.capitalize(pageName)}Apis } from './${mockFileName}'
|
|
1004
|
+
3. 在测试开始前调用 Mock 函数
|
|
1005
|
+
4. 包含基础的页面加载测试
|
|
1006
|
+
5. 必须包含一个 AI 视觉识别测试用例,使用 aiReviewScreenshot 检查页面内容
|
|
1007
|
+
6. 根据请求类型,生成合适的测试场景(如表单提交、数据加载等)
|
|
1008
|
+
7. 使用 Playwright 的最佳实践
|
|
1009
|
+
8. 包含有意义的断言
|
|
1010
|
+
9. 代码简洁清晰,有适当的注释
|
|
1011
|
+
|
|
1012
|
+
请直接返回测试代码,不需要额外的说明。
|
|
1013
|
+
`;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* 总结请求信息
|
|
1017
|
+
*/
|
|
1018
|
+
summarizeRequests(requests) {
|
|
1019
|
+
const summary = [];
|
|
1020
|
+
// 按方法统计
|
|
1021
|
+
const methodCount = {};
|
|
1022
|
+
requests.forEach((req) => {
|
|
1023
|
+
methodCount[req.method] = (methodCount[req.method] || 0) + 1;
|
|
1024
|
+
});
|
|
1025
|
+
summary.push('请求统计:');
|
|
1026
|
+
Object.entries(methodCount).forEach(([method, count]) => {
|
|
1027
|
+
summary.push(` - ${method}: ${count} 个请求`);
|
|
1028
|
+
});
|
|
1029
|
+
// 列出主要的 API 端点
|
|
1030
|
+
summary.push('\n主要 API 端点:');
|
|
1031
|
+
requests
|
|
1032
|
+
.filter((req) => req.response)
|
|
1033
|
+
.slice(0, 10) // 最多列出 10 个
|
|
1034
|
+
.forEach((req) => {
|
|
1035
|
+
const urlObj = new URL(req.url);
|
|
1036
|
+
summary.push(` - ${req.method} ${urlObj.pathname} (${req.response?.status})`);
|
|
1037
|
+
});
|
|
1038
|
+
return summary.join('\n');
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* 生成基础测试模板(不使用 AI)
|
|
1042
|
+
*/
|
|
1043
|
+
generateBasicTest(url, pageName, mockFileName) {
|
|
1044
|
+
const capitalizedName = this.capitalize(pageName);
|
|
1045
|
+
return `import { test, expect } from '@playwright/test';
|
|
1046
|
+
import { aiReviewScreenshot } from '@be-link/smart-test';
|
|
1047
|
+
import { mock${capitalizedName}Apis } from './${mockFileName}';
|
|
1048
|
+
|
|
1049
|
+
test.describe('${capitalizedName} 页面测试', () => {
|
|
1050
|
+
test.beforeEach(async ({ page }) => {
|
|
1051
|
+
// 设置 Mock 数据
|
|
1052
|
+
await mock${capitalizedName}Apis(page);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test('应该成功加载页面', async ({ page }) => {
|
|
1056
|
+
// 访问页面
|
|
1057
|
+
await page.goto('${url}');
|
|
1058
|
+
|
|
1059
|
+
// 等待页面加载
|
|
1060
|
+
await page.waitForLoadState('networkidle');
|
|
1061
|
+
|
|
1062
|
+
// 验证页面标题或关键元素
|
|
1063
|
+
await expect(page).toHaveURL('${url}');
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test('AI 视觉识别 - 页面内容检查', async ({ page }) => {
|
|
1067
|
+
// 访问页面
|
|
1068
|
+
await page.goto('${url}');
|
|
1069
|
+
|
|
1070
|
+
// 等待页面加载
|
|
1071
|
+
await page.waitForLoadState('networkidle');
|
|
1072
|
+
|
|
1073
|
+
// 使用 AI 视觉识别检查页面内容
|
|
1074
|
+
const result = await aiReviewScreenshot(page, {
|
|
1075
|
+
expected: '页面应该正常显示,没有明显的布局错误或内容缺失',
|
|
1076
|
+
fullPage: true,
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// 如果没有配置 AI,跳过测试
|
|
1080
|
+
if (result.skipped) {
|
|
1081
|
+
test.skip();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// 验证 AI 检查结果
|
|
1085
|
+
expect(result.ok, result.issues?.join(', ')).toBeTruthy();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// TODO: 添加更多测试用例
|
|
1089
|
+
});
|
|
1090
|
+
`;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* 转换为 PascalCase
|
|
1094
|
+
*/
|
|
1095
|
+
toPascalCase(str) {
|
|
1096
|
+
return str
|
|
1097
|
+
.split(/[-_\s]+/)
|
|
1098
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
1099
|
+
.join('');
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* 保存测试文件
|
|
1103
|
+
*/
|
|
1104
|
+
async saveTest(testCode, outputPath, pageName) {
|
|
1105
|
+
const fileName = `${this.toPascalCase(pageName)}.spec.ts`;
|
|
1106
|
+
const testPath = path.join(outputPath, fileName);
|
|
1107
|
+
await fs.mkdir(outputPath, { recursive: true });
|
|
1108
|
+
await fs.writeFile(testPath, testCode, 'utf-8');
|
|
1109
|
+
logger.success(`测试文件已保存: ${testPath}`);
|
|
1110
|
+
return testPath;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* 首字母大写
|
|
1114
|
+
*/
|
|
1115
|
+
capitalize(str) {
|
|
1116
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Page Object Model 生成器
|
|
1122
|
+
*/
|
|
1123
|
+
class POMGenerator {
|
|
1124
|
+
constructor(config) {
|
|
1125
|
+
this.config = config;
|
|
1126
|
+
this.openai = null;
|
|
1127
|
+
// 初始化 OpenAI 客户端
|
|
1128
|
+
if (config.ai?.apiKey) {
|
|
1129
|
+
this.openai = new OpenAI({
|
|
1130
|
+
apiKey: config.ai.apiKey,
|
|
1131
|
+
baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
1132
|
+
timeout: config.ai.timeout || 60000,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* 从页面提取元素信息
|
|
1138
|
+
*/
|
|
1139
|
+
async extractElements(page) {
|
|
1140
|
+
logger.debug('提取页面元素信息...');
|
|
1141
|
+
const elements = await page.evaluate(() => {
|
|
1142
|
+
// 辅助函数:生成选择器
|
|
1143
|
+
function generateSelector(el) {
|
|
1144
|
+
// 优先使用 id
|
|
1145
|
+
if (el.id) {
|
|
1146
|
+
return `#${el.id}`;
|
|
1147
|
+
}
|
|
1148
|
+
// 使用 name 属性
|
|
1149
|
+
const name = el.getAttribute('name');
|
|
1150
|
+
if (name) {
|
|
1151
|
+
return `[name="${name}"]`;
|
|
1152
|
+
}
|
|
1153
|
+
// 使用 data-testid
|
|
1154
|
+
const testId = el.getAttribute('data-testid');
|
|
1155
|
+
if (testId) {
|
|
1156
|
+
return `[data-testid="${testId}"]`;
|
|
1157
|
+
}
|
|
1158
|
+
// 使用类名(取第一个)
|
|
1159
|
+
if (el.className && typeof el.className === 'string') {
|
|
1160
|
+
const firstClass = el.className.split(' ')[0];
|
|
1161
|
+
if (firstClass) {
|
|
1162
|
+
return `.${firstClass}`;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
// 使用标签名
|
|
1166
|
+
return el.tagName.toLowerCase();
|
|
1167
|
+
}
|
|
1168
|
+
// 辅助函数:推断描述
|
|
1169
|
+
function inferDescription(el) {
|
|
1170
|
+
const tag = el.tagName.toLowerCase();
|
|
1171
|
+
// 从文本内容推断
|
|
1172
|
+
const text = el.textContent?.trim();
|
|
1173
|
+
if (text && text.length < 30) {
|
|
1174
|
+
return text.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]+/g, ' ').trim();
|
|
1175
|
+
}
|
|
1176
|
+
// 从属性推断
|
|
1177
|
+
const placeholder = el.getAttribute('placeholder');
|
|
1178
|
+
if (placeholder) {
|
|
1179
|
+
return placeholder;
|
|
1180
|
+
}
|
|
1181
|
+
const name = el.getAttribute('name');
|
|
1182
|
+
if (name) {
|
|
1183
|
+
return name;
|
|
1184
|
+
}
|
|
1185
|
+
const id = el.getAttribute('id');
|
|
1186
|
+
if (id) {
|
|
1187
|
+
return id;
|
|
1188
|
+
}
|
|
1189
|
+
const type = el.getAttribute('type');
|
|
1190
|
+
if (type) {
|
|
1191
|
+
return `${type} ${tag}`;
|
|
1192
|
+
}
|
|
1193
|
+
return tag;
|
|
1194
|
+
}
|
|
1195
|
+
// 辅助函数:获取属性
|
|
1196
|
+
function getAttributes(el) {
|
|
1197
|
+
const attrs = {};
|
|
1198
|
+
['id', 'name', 'type', 'placeholder', 'value', 'href', 'role', 'data-testid'].forEach((attrName) => {
|
|
1199
|
+
const value = el.getAttribute(attrName);
|
|
1200
|
+
if (value) {
|
|
1201
|
+
attrs[attrName] = value;
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
return attrs;
|
|
1205
|
+
}
|
|
1206
|
+
const results = [];
|
|
1207
|
+
// 提取输入框
|
|
1208
|
+
document.querySelectorAll('input, textarea').forEach((el) => {
|
|
1209
|
+
const input = el;
|
|
1210
|
+
results.push({
|
|
1211
|
+
type: input.tagName.toLowerCase() === 'textarea' ? 'textarea' : 'input',
|
|
1212
|
+
selector: generateSelector(input),
|
|
1213
|
+
description: inferDescription(input),
|
|
1214
|
+
attributes: getAttributes(input),
|
|
1215
|
+
text: input.value,
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
// 提取按钮
|
|
1219
|
+
document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el) => {
|
|
1220
|
+
const button = el;
|
|
1221
|
+
results.push({
|
|
1222
|
+
type: 'button',
|
|
1223
|
+
selector: generateSelector(button),
|
|
1224
|
+
description: inferDescription(button),
|
|
1225
|
+
attributes: getAttributes(button),
|
|
1226
|
+
text: button.textContent?.trim() || '',
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
// 提取链接
|
|
1230
|
+
document.querySelectorAll('a[href]').forEach((el) => {
|
|
1231
|
+
const link = el;
|
|
1232
|
+
results.push({
|
|
1233
|
+
type: 'link',
|
|
1234
|
+
selector: generateSelector(link),
|
|
1235
|
+
description: inferDescription(link),
|
|
1236
|
+
attributes: getAttributes(link),
|
|
1237
|
+
text: link.textContent?.trim() || '',
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
// 提取下拉框
|
|
1241
|
+
document.querySelectorAll('select').forEach((el) => {
|
|
1242
|
+
const select = el;
|
|
1243
|
+
results.push({
|
|
1244
|
+
type: 'select',
|
|
1245
|
+
selector: generateSelector(select),
|
|
1246
|
+
description: inferDescription(select),
|
|
1247
|
+
attributes: getAttributes(select),
|
|
1248
|
+
text: select.selectedOptions[0]?.text || '',
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
return results;
|
|
1252
|
+
});
|
|
1253
|
+
logger.debug(`提取到 ${elements.length} 个页面元素`);
|
|
1254
|
+
return elements;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* 生成 Page Object 代码
|
|
1258
|
+
*/
|
|
1259
|
+
async generatePOM(pageName, pageUrl, elements) {
|
|
1260
|
+
const capitalizedName = this.capitalize(pageName);
|
|
1261
|
+
const className = `${capitalizedName}Page`;
|
|
1262
|
+
// 如果没有元素,生成基础 POM
|
|
1263
|
+
if (elements.length === 0) {
|
|
1264
|
+
return this.generateBasicPOM(className, pageUrl);
|
|
1265
|
+
}
|
|
1266
|
+
// 使用 AI 优化元素命名(如果可用)
|
|
1267
|
+
let optimizedElements = elements;
|
|
1268
|
+
if (this.openai) {
|
|
1269
|
+
try {
|
|
1270
|
+
optimizedElements = await this.optimizeElementNames(elements);
|
|
1271
|
+
}
|
|
1272
|
+
catch (error) {
|
|
1273
|
+
logger.warn(`AI 优化元素命名失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
// 生成代码
|
|
1277
|
+
let code = `import type { Page, Locator } from '@playwright/test';\n\n`;
|
|
1278
|
+
code += `/**\n`;
|
|
1279
|
+
code += ` * ${capitalizedName} 页面 Page Object\n`;
|
|
1280
|
+
code += ` */\n`;
|
|
1281
|
+
code += `export class ${className} {\n`;
|
|
1282
|
+
code += ` readonly url = '${pageUrl}';\n\n`;
|
|
1283
|
+
// 生成元素定位器声明
|
|
1284
|
+
code += ` // 页面元素\n`;
|
|
1285
|
+
optimizedElements.forEach((el) => {
|
|
1286
|
+
const propertyName = this.toPropertyName(el.description);
|
|
1287
|
+
code += ` readonly ${propertyName}: Locator;\n`;
|
|
1288
|
+
});
|
|
1289
|
+
code += `\n`;
|
|
1290
|
+
code += ` constructor(private page: Page) {\n`;
|
|
1291
|
+
// 生成元素初始化
|
|
1292
|
+
optimizedElements.forEach((el) => {
|
|
1293
|
+
const propertyName = this.toPropertyName(el.description);
|
|
1294
|
+
code += ` this.${propertyName} = page.locator('${el.selector}');\n`;
|
|
1295
|
+
});
|
|
1296
|
+
code += ` }\n\n`;
|
|
1297
|
+
// 生成基础方法
|
|
1298
|
+
code += ` /**\n`;
|
|
1299
|
+
code += ` * 访问页面\n`;
|
|
1300
|
+
code += ` */\n`;
|
|
1301
|
+
code += ` async goto() {\n`;
|
|
1302
|
+
code += ` await this.page.goto(this.url);\n`;
|
|
1303
|
+
code += ` }\n\n`;
|
|
1304
|
+
code += ` /**\n`;
|
|
1305
|
+
code += ` * 等待页面加载完成\n`;
|
|
1306
|
+
code += ` */\n`;
|
|
1307
|
+
code += ` async waitForLoad() {\n`;
|
|
1308
|
+
code += ` await this.page.waitForLoadState('networkidle');\n`;
|
|
1309
|
+
code += ` }\n`;
|
|
1310
|
+
code += `}\n`;
|
|
1311
|
+
return code;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* 使用 AI 优化元素命名
|
|
1315
|
+
*/
|
|
1316
|
+
async optimizeElementNames(elements) {
|
|
1317
|
+
if (!this.openai) {
|
|
1318
|
+
return elements;
|
|
1319
|
+
}
|
|
1320
|
+
logger.debug('使用 AI 优化元素命名...');
|
|
1321
|
+
const elementsSummary = elements
|
|
1322
|
+
.map((el, index) => {
|
|
1323
|
+
return `${index + 1}. ${el.type} - selector: ${el.selector}, text: "${el.text || ''}", name: "${el.attributes.name || ''}", id: "${el.attributes.id || ''}"`;
|
|
1324
|
+
})
|
|
1325
|
+
.join('\n');
|
|
1326
|
+
const prompt = `
|
|
1327
|
+
请为以下页面元素生成语义化的属性名称(使用小驼峰命名法)。
|
|
1328
|
+
|
|
1329
|
+
元素列表:
|
|
1330
|
+
${elementsSummary}
|
|
1331
|
+
|
|
1332
|
+
要求:
|
|
1333
|
+
1. 属性名要体现元素的用途
|
|
1334
|
+
2. 使用英文,小驼峰命名法
|
|
1335
|
+
3. 简洁清晰
|
|
1336
|
+
4. 对于按钮、链接,从文本内容推断用途
|
|
1337
|
+
5. 对于输入框,从 name/id/placeholder 推断用途
|
|
1338
|
+
|
|
1339
|
+
请以 JSON 数组格式返回,每个元素包含 index 和 propertyName:
|
|
1340
|
+
[{"index": 1, "propertyName": "usernameInput"}, ...]
|
|
1341
|
+
`;
|
|
1342
|
+
try {
|
|
1343
|
+
const response = await this.openai.chat.completions.create({
|
|
1344
|
+
model: this.config.ai?.model || 'qwen-vl-plus',
|
|
1345
|
+
messages: [
|
|
1346
|
+
{
|
|
1347
|
+
role: 'system',
|
|
1348
|
+
content: '你是一个专业的前端工程师,擅长为 UI 元素命名。',
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
role: 'user',
|
|
1352
|
+
content: prompt,
|
|
1353
|
+
},
|
|
1354
|
+
],
|
|
1355
|
+
temperature: 0.3,
|
|
1356
|
+
});
|
|
1357
|
+
const result = response.choices[0]?.message?.content || '';
|
|
1358
|
+
// 提取 JSON
|
|
1359
|
+
const jsonMatch = result.match(/\[[\s\S]*\]/);
|
|
1360
|
+
if (jsonMatch) {
|
|
1361
|
+
const naming = JSON.parse(jsonMatch[0]);
|
|
1362
|
+
// 应用命名
|
|
1363
|
+
return elements.map((el, index) => {
|
|
1364
|
+
const nameData = naming.find((n) => n.index === index + 1);
|
|
1365
|
+
if (nameData && nameData.propertyName) {
|
|
1366
|
+
return { ...el, description: nameData.propertyName };
|
|
1367
|
+
}
|
|
1368
|
+
return el;
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
logger.debug(`AI 命名解析失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1374
|
+
}
|
|
1375
|
+
return elements;
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* 生成基础 POM(无元素)
|
|
1379
|
+
*/
|
|
1380
|
+
generateBasicPOM(className, pageUrl) {
|
|
1381
|
+
return `import type { Page } from '@playwright/test';
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* ${className} Page Object
|
|
1385
|
+
*/
|
|
1386
|
+
export class ${className} {
|
|
1387
|
+
readonly url = '${pageUrl}';
|
|
1388
|
+
|
|
1389
|
+
constructor(private page: Page) {}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* 访问页面
|
|
1393
|
+
*/
|
|
1394
|
+
async goto() {
|
|
1395
|
+
await this.page.goto(this.url);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* 等待页面加载完成
|
|
1400
|
+
*/
|
|
1401
|
+
async waitForLoad() {
|
|
1402
|
+
await this.page.waitForLoadState('networkidle');
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* 将描述转换为属性名
|
|
1409
|
+
*/
|
|
1410
|
+
toPropertyName(description) {
|
|
1411
|
+
// 移除特殊字符,转换为小驼峰
|
|
1412
|
+
return description
|
|
1413
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
1414
|
+
.trim()
|
|
1415
|
+
.split(' ')
|
|
1416
|
+
.map((word, index) => {
|
|
1417
|
+
word = word.toLowerCase();
|
|
1418
|
+
if (index > 0) {
|
|
1419
|
+
word = word.charAt(0).toUpperCase() + word.slice(1);
|
|
1420
|
+
}
|
|
1421
|
+
return word;
|
|
1422
|
+
})
|
|
1423
|
+
.join('');
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* 转换为 PascalCase
|
|
1427
|
+
*/
|
|
1428
|
+
toPascalCase(str) {
|
|
1429
|
+
return str
|
|
1430
|
+
.split(/[-_\s]+/)
|
|
1431
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
1432
|
+
.join('');
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* 保存 POM 文件
|
|
1436
|
+
*/
|
|
1437
|
+
async savePOM(pomCode, outputPath, pageName) {
|
|
1438
|
+
const fileName = `${this.toPascalCase(pageName)}Page.ts`;
|
|
1439
|
+
const pomPath = path.join(outputPath, fileName);
|
|
1440
|
+
await fs.mkdir(outputPath, { recursive: true });
|
|
1441
|
+
await fs.writeFile(pomPath, pomCode, 'utf-8');
|
|
1442
|
+
logger.success(`POM 文件已保存: ${pomPath}`);
|
|
1443
|
+
return pomPath;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* 首字母大写
|
|
1447
|
+
*/
|
|
1448
|
+
capitalize(str) {
|
|
1449
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* AI 交互式引导
|
|
1455
|
+
*/
|
|
1456
|
+
class InteractiveGuide {
|
|
1457
|
+
constructor(page, config) {
|
|
1458
|
+
this.page = page;
|
|
1459
|
+
this.config = config;
|
|
1460
|
+
this.openai = null;
|
|
1461
|
+
this.pomGenerator = new POMGenerator(config);
|
|
1462
|
+
this.context = {
|
|
1463
|
+
url: page.url(),
|
|
1464
|
+
elements: [],
|
|
1465
|
+
executedActions: [],
|
|
1466
|
+
currentStep: 0,
|
|
1467
|
+
};
|
|
1468
|
+
// 初始化 OpenAI 客户端
|
|
1469
|
+
if (config.ai?.apiKey) {
|
|
1470
|
+
this.openai = new OpenAI({
|
|
1471
|
+
apiKey: config.ai.apiKey,
|
|
1472
|
+
baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
1473
|
+
timeout: config.ai.timeout || 60000,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* 启动交互式引导
|
|
1479
|
+
*/
|
|
1480
|
+
async start() {
|
|
1481
|
+
logger.newLine();
|
|
1482
|
+
logger.ai('🤖 AI 交互式引导模式启动');
|
|
1483
|
+
logger.info('AI 将分析页面并为您提供测试操作建议喵~');
|
|
1484
|
+
logger.newLine();
|
|
1485
|
+
try {
|
|
1486
|
+
// 主循环
|
|
1487
|
+
while (true) {
|
|
1488
|
+
// 1. 分析页面
|
|
1489
|
+
await this.analyzePageContext();
|
|
1490
|
+
// 2. 生成建议
|
|
1491
|
+
const suggestions = await this.generateSuggestions();
|
|
1492
|
+
if (suggestions.length === 0) {
|
|
1493
|
+
logger.warn('没有更多的操作建议了喵~');
|
|
1494
|
+
break;
|
|
1495
|
+
}
|
|
1496
|
+
// 3. 展示建议并获取用户选择
|
|
1497
|
+
const selectedAction = await this.displaySuggestions(suggestions);
|
|
1498
|
+
// 4. 检查是否结束
|
|
1499
|
+
if (selectedAction.type === 'finish') {
|
|
1500
|
+
logger.success('录制已结束喵~');
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
// 5. 执行操作
|
|
1504
|
+
await this.executeAction(selectedAction);
|
|
1505
|
+
// 6. 记录操作
|
|
1506
|
+
this.context.executedActions.push(selectedAction);
|
|
1507
|
+
this.context.currentStep++;
|
|
1508
|
+
// 等待页面可能的状态变化
|
|
1509
|
+
await this.page.waitForTimeout(1000);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
catch (error) {
|
|
1513
|
+
logger.error(`交互式引导出错: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
throw error;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* 分析页面当前上下文
|
|
1519
|
+
*/
|
|
1520
|
+
async analyzePageContext() {
|
|
1521
|
+
logger.startSpinner('正在分析页面结构...');
|
|
1522
|
+
try {
|
|
1523
|
+
// 提取页面元素
|
|
1524
|
+
this.context.elements = await this.pomGenerator.extractElements(this.page);
|
|
1525
|
+
this.context.url = this.page.url();
|
|
1526
|
+
logger.succeedSpinner(`发现 ${this.context.elements.length} 个可操作元素`);
|
|
1527
|
+
}
|
|
1528
|
+
catch (error) {
|
|
1529
|
+
logger.failSpinner('页面分析失败');
|
|
1530
|
+
throw error;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* 生成操作建议
|
|
1535
|
+
*/
|
|
1536
|
+
async generateSuggestions() {
|
|
1537
|
+
// 如果没有 AI,返回基础建议
|
|
1538
|
+
if (!this.openai) {
|
|
1539
|
+
return this.generateBasicSuggestions();
|
|
1540
|
+
}
|
|
1541
|
+
logger.startSpinner('AI 正在生成测试建议...');
|
|
1542
|
+
try {
|
|
1543
|
+
const prompt = this.buildSuggestionsPrompt();
|
|
1544
|
+
const response = await this.openai.chat.completions.create({
|
|
1545
|
+
model: this.config.ai?.model || 'qwen-vl-plus',
|
|
1546
|
+
messages: [
|
|
1547
|
+
{
|
|
1548
|
+
role: 'system',
|
|
1549
|
+
content: '你是一个专业的测试工程师。根据页面元素和已执行的操作,建议接下来应该执行的测试操作。' +
|
|
1550
|
+
'请以 JSON 数组格式返回建议,每个建议包含:type(操作类型)、elementIndex(元素索引,-1 表示无需元素)、' +
|
|
1551
|
+
'description(操作描述)、value(操作值,可选)、priority(优先级 1-5)、reason(理由)。' +
|
|
1552
|
+
'操作类型包括:fill(填写)、click(点击)、select(选择)、verify(验证)、wait(等待)、finish(结束)。',
|
|
1553
|
+
},
|
|
1554
|
+
{
|
|
1555
|
+
role: 'user',
|
|
1556
|
+
content: prompt,
|
|
1557
|
+
},
|
|
1558
|
+
],
|
|
1559
|
+
temperature: 0.5,
|
|
1560
|
+
response_format: { type: 'json_object' },
|
|
1561
|
+
});
|
|
1562
|
+
const content = response.choices[0]?.message?.content || '{}';
|
|
1563
|
+
const result = JSON.parse(content);
|
|
1564
|
+
// 解析 AI 返回的建议
|
|
1565
|
+
const suggestions = (result.suggestions || []).map((s, index) => ({
|
|
1566
|
+
id: `ai-${this.context.currentStep}-${index}`,
|
|
1567
|
+
type: s.type || 'click',
|
|
1568
|
+
element: s.elementIndex >= 0 ? this.context.elements[s.elementIndex] : undefined,
|
|
1569
|
+
description: s.description || '未知操作',
|
|
1570
|
+
value: s.value,
|
|
1571
|
+
priority: s.priority || 3,
|
|
1572
|
+
reason: s.reason,
|
|
1573
|
+
}));
|
|
1574
|
+
// 始终添加"结束录制"选项
|
|
1575
|
+
suggestions.push({
|
|
1576
|
+
id: 'finish',
|
|
1577
|
+
type: 'finish',
|
|
1578
|
+
description: '结束录制',
|
|
1579
|
+
priority: 1,
|
|
1580
|
+
});
|
|
1581
|
+
logger.succeedSpinner(`生成了 ${suggestions.length - 1} 个测试建议`);
|
|
1582
|
+
return suggestions.sort((a, b) => b.priority - a.priority);
|
|
1583
|
+
}
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
logger.failSpinner('AI 建议生成失败');
|
|
1586
|
+
logger.warn(`将使用基础建议: ${error instanceof Error ? error.message : String(error)}`);
|
|
1587
|
+
return this.generateBasicSuggestions();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* 构建 AI 提示词
|
|
1592
|
+
*/
|
|
1593
|
+
buildSuggestionsPrompt() {
|
|
1594
|
+
const elementsInfo = this.context.elements
|
|
1595
|
+
.map((el, idx) => `${idx}. [${el.type}] ${el.description || '未命名元素'} (${el.selector})${el.text ? ` - 文本: "${el.text}"` : ''}`)
|
|
1596
|
+
.join('\n');
|
|
1597
|
+
const actionsInfo = this.context.executedActions.length > 0
|
|
1598
|
+
? this.context.executedActions
|
|
1599
|
+
.map((a, idx) => `${idx + 1}. ${a.description}${a.value ? ` (值: ${a.value})` : ''}`)
|
|
1600
|
+
.join('\n')
|
|
1601
|
+
: '无';
|
|
1602
|
+
return `
|
|
1603
|
+
当前页面 URL: ${this.context.url}
|
|
1604
|
+
当前步骤: ${this.context.currentStep + 1}
|
|
1605
|
+
|
|
1606
|
+
可操作元素列表:
|
|
1607
|
+
${elementsInfo}
|
|
1608
|
+
|
|
1609
|
+
已执行的操作:
|
|
1610
|
+
${actionsInfo}
|
|
1611
|
+
|
|
1612
|
+
请分析当前页面状态,建议接下来应该执行的测试操作。
|
|
1613
|
+
要求:
|
|
1614
|
+
1. 建议应该符合真实的测试场景(如:填写表单 → 点击提交 → 验证结果)
|
|
1615
|
+
2. 不要重复已执行的操作
|
|
1616
|
+
3. 优先建议高价值的测试操作
|
|
1617
|
+
4. 每次建议 2-4 个操作
|
|
1618
|
+
5. 如果已经完成了基本的测试流程,可以建议结束
|
|
1619
|
+
|
|
1620
|
+
返回 JSON 格式: {"suggestions": [{"type": "...", "elementIndex": 0, "description": "...", "value": "...", "priority": 5, "reason": "..."}]}
|
|
1621
|
+
`;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* 生成基础建议(不使用 AI)
|
|
1625
|
+
*/
|
|
1626
|
+
generateBasicSuggestions() {
|
|
1627
|
+
const suggestions = [];
|
|
1628
|
+
// 为每个未操作的元素生成建议
|
|
1629
|
+
const executedElements = new Set(this.context.executedActions.map((a) => a.element?.selector).filter(Boolean));
|
|
1630
|
+
this.context.elements
|
|
1631
|
+
.filter((el) => !executedElements.has(el.selector))
|
|
1632
|
+
.slice(0, 5) // 最多显示 5 个
|
|
1633
|
+
.forEach((el, index) => {
|
|
1634
|
+
const suggestion = {
|
|
1635
|
+
id: `basic-${this.context.currentStep}-${index}`,
|
|
1636
|
+
type: el.type === 'input' || el.type === 'textarea' ? 'fill' : 'click',
|
|
1637
|
+
element: el,
|
|
1638
|
+
description: this.getBasicSuggestionDescription(el),
|
|
1639
|
+
priority: 3,
|
|
1640
|
+
};
|
|
1641
|
+
if (suggestion.type === 'fill') {
|
|
1642
|
+
suggestion.value = `测试${el.description || '输入'}`;
|
|
1643
|
+
}
|
|
1644
|
+
suggestions.push(suggestion);
|
|
1645
|
+
});
|
|
1646
|
+
// 添加"结束录制"选项
|
|
1647
|
+
suggestions.push({
|
|
1648
|
+
id: 'finish',
|
|
1649
|
+
type: 'finish',
|
|
1650
|
+
description: '结束录制',
|
|
1651
|
+
priority: 1,
|
|
1652
|
+
});
|
|
1653
|
+
return suggestions;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* 获取基础建议描述
|
|
1657
|
+
*/
|
|
1658
|
+
getBasicSuggestionDescription(element) {
|
|
1659
|
+
if (element.type === 'input' || element.type === 'textarea') {
|
|
1660
|
+
return `填写 "${element.description || element.selector}"`;
|
|
1661
|
+
}
|
|
1662
|
+
if (element.type === 'button') {
|
|
1663
|
+
return `点击按钮 "${element.description || element.text || element.selector}"`;
|
|
1664
|
+
}
|
|
1665
|
+
if (element.type === 'link') {
|
|
1666
|
+
return `点击链接 "${element.description || element.text || element.selector}"`;
|
|
1667
|
+
}
|
|
1668
|
+
if (element.type === 'select') {
|
|
1669
|
+
return `选择 "${element.description || element.selector}"`;
|
|
1670
|
+
}
|
|
1671
|
+
return `操作 "${element.description || element.selector}"`;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* 展示建议并获取用户选择
|
|
1675
|
+
*/
|
|
1676
|
+
async displaySuggestions(suggestions) {
|
|
1677
|
+
logger.newLine();
|
|
1678
|
+
logger.info('📋 AI 建议的测试操作:');
|
|
1679
|
+
logger.newLine();
|
|
1680
|
+
// 构建选项列表
|
|
1681
|
+
const choices = suggestions.map((s) => {
|
|
1682
|
+
let name = `${this.getPriorityIcon(s.priority)} ${s.description}`;
|
|
1683
|
+
if (s.reason) {
|
|
1684
|
+
name += `\n 💡 ${s.reason}`;
|
|
1685
|
+
}
|
|
1686
|
+
if (s.value) {
|
|
1687
|
+
name += `\n ✏️ 值: "${s.value}"`;
|
|
1688
|
+
}
|
|
1689
|
+
return {
|
|
1690
|
+
name,
|
|
1691
|
+
value: s.id,
|
|
1692
|
+
short: s.description,
|
|
1693
|
+
};
|
|
1694
|
+
});
|
|
1695
|
+
// 让用户选择
|
|
1696
|
+
const answer = await inquirer.prompt([
|
|
1697
|
+
{
|
|
1698
|
+
type: 'list',
|
|
1699
|
+
name: 'action',
|
|
1700
|
+
message: '请选择要执行的操作:',
|
|
1701
|
+
choices,
|
|
1702
|
+
pageSize: 10,
|
|
1703
|
+
},
|
|
1704
|
+
]);
|
|
1705
|
+
const selected = suggestions.find((s) => s.id === answer.action);
|
|
1706
|
+
if (!selected) {
|
|
1707
|
+
throw new Error('无效的选择');
|
|
1708
|
+
}
|
|
1709
|
+
// 如果是填写操作且没有预设值,询问用户输入
|
|
1710
|
+
if (selected.type === 'fill' && !selected.value) {
|
|
1711
|
+
const inputAnswer = await inquirer.prompt([
|
|
1712
|
+
{
|
|
1713
|
+
type: 'input',
|
|
1714
|
+
name: 'value',
|
|
1715
|
+
message: `请输入要填写的内容:`,
|
|
1716
|
+
default: selected.value || `测试${selected.element?.description || '输入'}`,
|
|
1717
|
+
},
|
|
1718
|
+
]);
|
|
1719
|
+
selected.value = inputAnswer.value;
|
|
1720
|
+
}
|
|
1721
|
+
return selected;
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* 获取优先级图标
|
|
1725
|
+
*/
|
|
1726
|
+
getPriorityIcon(priority) {
|
|
1727
|
+
if (priority >= 5)
|
|
1728
|
+
return '⭐⭐⭐';
|
|
1729
|
+
if (priority >= 4)
|
|
1730
|
+
return '⭐⭐';
|
|
1731
|
+
if (priority >= 3)
|
|
1732
|
+
return '⭐';
|
|
1733
|
+
return '◦';
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* 执行操作
|
|
1737
|
+
*/
|
|
1738
|
+
async executeAction(action) {
|
|
1739
|
+
logger.newLine();
|
|
1740
|
+
logger.startSpinner(`执行操作: ${action.description}`);
|
|
1741
|
+
try {
|
|
1742
|
+
switch (action.type) {
|
|
1743
|
+
case 'fill':
|
|
1744
|
+
if (action.element && action.value) {
|
|
1745
|
+
await this.page.locator(action.element.selector).fill(action.value);
|
|
1746
|
+
logger.succeedSpinner(`已填写: "${action.value}"`);
|
|
1747
|
+
}
|
|
1748
|
+
break;
|
|
1749
|
+
case 'click':
|
|
1750
|
+
if (action.element) {
|
|
1751
|
+
await this.page.locator(action.element.selector).click();
|
|
1752
|
+
logger.succeedSpinner('点击成功');
|
|
1753
|
+
}
|
|
1754
|
+
break;
|
|
1755
|
+
case 'select':
|
|
1756
|
+
if (action.element && action.value) {
|
|
1757
|
+
await this.page.locator(action.element.selector).selectOption(action.value);
|
|
1758
|
+
logger.succeedSpinner(`已选择: "${action.value}"`);
|
|
1759
|
+
}
|
|
1760
|
+
break;
|
|
1761
|
+
case 'verify':
|
|
1762
|
+
if (action.element) {
|
|
1763
|
+
const isVisible = await this.page.locator(action.element.selector).isVisible();
|
|
1764
|
+
logger.succeedSpinner(`验证${isVisible ? '成功' : '失败'}`);
|
|
1765
|
+
}
|
|
1766
|
+
break;
|
|
1767
|
+
case 'wait':
|
|
1768
|
+
await this.page.waitForLoadState('networkidle');
|
|
1769
|
+
logger.succeedSpinner('等待完成');
|
|
1770
|
+
break;
|
|
1771
|
+
case 'navigate':
|
|
1772
|
+
if (action.value) {
|
|
1773
|
+
await this.page.goto(action.value);
|
|
1774
|
+
logger.succeedSpinner('导航成功');
|
|
1775
|
+
}
|
|
1776
|
+
break;
|
|
1777
|
+
case 'finish':
|
|
1778
|
+
// 不需要执行任何操作
|
|
1779
|
+
break;
|
|
1780
|
+
default:
|
|
1781
|
+
logger.warn(`未知的操作类型: ${action.type}`);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
catch (error) {
|
|
1785
|
+
logger.failSpinner(`操作失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1786
|
+
throw error;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* 获取执行的操作历史
|
|
1791
|
+
*/
|
|
1792
|
+
getExecutedActions() {
|
|
1793
|
+
return this.context.executedActions;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* 录制命令
|
|
1799
|
+
*/
|
|
1800
|
+
async function recordCommand(options) {
|
|
1801
|
+
logger.title('[smart-test] 录制模式 - 生成测试代码');
|
|
1802
|
+
// 加载配置
|
|
1803
|
+
const config = await configLoader.load();
|
|
1804
|
+
// 设置默认值
|
|
1805
|
+
const recordOptions = {
|
|
1806
|
+
...options,
|
|
1807
|
+
output: options.output || config.output?.helpersDir || './tests/helpers',
|
|
1808
|
+
browser: options.browser || config.playwright?.browser || 'chromium',
|
|
1809
|
+
viewport: options.viewport || config.playwright?.viewport,
|
|
1810
|
+
timeout: options.timeout || 60,
|
|
1811
|
+
};
|
|
1812
|
+
logger.info(`录制 URL: ${recordOptions.url}`);
|
|
1813
|
+
logger.info(`输出目录: ${recordOptions.output}`);
|
|
1814
|
+
logger.info(`浏览器: ${recordOptions.browser}`);
|
|
1815
|
+
// 创建录制器
|
|
1816
|
+
const recorder = new NetworkRecorder();
|
|
1817
|
+
try {
|
|
1818
|
+
// 启动录制
|
|
1819
|
+
await recorder.start(recordOptions);
|
|
1820
|
+
// 根据模式等待用户操作
|
|
1821
|
+
if (options.manual) {
|
|
1822
|
+
logger.newLine();
|
|
1823
|
+
logger.ai('手动操作模式');
|
|
1824
|
+
logger.info('🌐 浏览器已打开,请在浏览器中进行操作');
|
|
1825
|
+
logger.info('📹 所有网络请求都在录制中');
|
|
1826
|
+
logger.prompt('完成操作后,请按 Ctrl+C 停止录制');
|
|
1827
|
+
logger.newLine();
|
|
1828
|
+
// 等待用户操作
|
|
1829
|
+
await recorder.waitForUserInteraction(recordOptions.timeout);
|
|
1830
|
+
}
|
|
1831
|
+
else if (options.interactive) {
|
|
1832
|
+
const page = recorder.getPage();
|
|
1833
|
+
if (!page) {
|
|
1834
|
+
logger.error('无法获取页面实例');
|
|
1835
|
+
throw new Error('无法启动交互式引导模式');
|
|
1836
|
+
}
|
|
1837
|
+
// 启动 AI 交互式引导
|
|
1838
|
+
const interactiveGuide = new InteractiveGuide(page, config);
|
|
1839
|
+
await interactiveGuide.start();
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
logger.newLine();
|
|
1843
|
+
logger.ai('自动录制模式');
|
|
1844
|
+
logger.info('将自动在 5 秒后停止录制');
|
|
1845
|
+
logger.newLine();
|
|
1846
|
+
// 自动模式,等待几秒
|
|
1847
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
1848
|
+
}
|
|
1849
|
+
// 提取页面元素(在关闭浏览器前)
|
|
1850
|
+
logger.newLine();
|
|
1851
|
+
logger.startSpinner('正在分析页面结构...');
|
|
1852
|
+
const page = recorder.getPage();
|
|
1853
|
+
const pomGenerator = new POMGenerator(config);
|
|
1854
|
+
let pageElements = [];
|
|
1855
|
+
if (page) {
|
|
1856
|
+
try {
|
|
1857
|
+
pageElements = await pomGenerator.extractElements(page);
|
|
1858
|
+
logger.succeedSpinner(`页面结构分析完成,发现 ${pageElements.length} 个元素`);
|
|
1859
|
+
}
|
|
1860
|
+
catch (error) {
|
|
1861
|
+
logger.failSpinner('页面结构分析失败');
|
|
1862
|
+
logger.warn(`提取元素失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
logger.failSpinner('无法获取页面实例');
|
|
1867
|
+
}
|
|
1868
|
+
// 停止录制
|
|
1869
|
+
const requests = await recorder.stop();
|
|
1870
|
+
// 显示统计
|
|
1871
|
+
recorder.displayStats();
|
|
1872
|
+
if (requests.length === 0) {
|
|
1873
|
+
logger.warn('未捕获到任何请求');
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
// 过滤出有响应的请求
|
|
1877
|
+
const validRequests = requests.filter((r) => r.response);
|
|
1878
|
+
logger.info(`有效请求数: ${validRequests.length}`);
|
|
1879
|
+
if (validRequests.length === 0) {
|
|
1880
|
+
logger.warn('没有有效的请求(所有请求都缺少响应)');
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
// 生成文件
|
|
1884
|
+
logger.newLine();
|
|
1885
|
+
logger.startSpinner('正在生成 Mock 代码...');
|
|
1886
|
+
const converter = new HARConverter();
|
|
1887
|
+
// 提取页面名称
|
|
1888
|
+
const pageName = extractPageName(recordOptions.url);
|
|
1889
|
+
const pascalCaseName = toPascalCase(pageName);
|
|
1890
|
+
logger.debug(`页面名称: ${pageName} (PascalCase: ${pascalCaseName})`);
|
|
1891
|
+
// 保存 HAR 文件
|
|
1892
|
+
const outputDir = path.resolve(process.cwd(), recordOptions.output);
|
|
1893
|
+
await converter.saveHAR(validRequests, outputDir);
|
|
1894
|
+
// 生成并保存 Mock 代码
|
|
1895
|
+
const mockFileName = `Mock${pascalCaseName}`;
|
|
1896
|
+
await converter.saveMockCode(validRequests, outputDir, pageName);
|
|
1897
|
+
logger.succeedSpinner('Mock 代码生成完成');
|
|
1898
|
+
// 生成测试用例
|
|
1899
|
+
logger.newLine();
|
|
1900
|
+
logger.startSpinner('正在生成测试用例...');
|
|
1901
|
+
const testGenerator = new TestGenerator(config);
|
|
1902
|
+
const testCode = await testGenerator.generateTest(recordOptions.url, pageName, validRequests, mockFileName);
|
|
1903
|
+
await testGenerator.saveTest(testCode, outputDir, pageName);
|
|
1904
|
+
logger.succeedSpinner('测试用例生成完成');
|
|
1905
|
+
// 生成 Page Object Model
|
|
1906
|
+
logger.newLine();
|
|
1907
|
+
logger.startSpinner('正在生成 Page Object Model...');
|
|
1908
|
+
const pomCode = await pomGenerator.generatePOM(pageName, recordOptions.url, pageElements);
|
|
1909
|
+
await pomGenerator.savePOM(pomCode, outputDir, pageName);
|
|
1910
|
+
logger.succeedSpinner('POM 代码生成完成');
|
|
1911
|
+
// 下一步提示
|
|
1912
|
+
logger.newLine();
|
|
1913
|
+
logger.success('✅ 录制完成!');
|
|
1914
|
+
logger.newLine();
|
|
1915
|
+
logger.info('生成的文件:');
|
|
1916
|
+
logger.info(` 📄 ${path.join(outputDir, 'recorded.har')}`);
|
|
1917
|
+
logger.info(` 📄 ${path.join(outputDir, `${mockFileName}.ts`)}`);
|
|
1918
|
+
logger.info(` 📄 ${path.join(outputDir, `${pascalCaseName}.spec.ts`)}`);
|
|
1919
|
+
logger.info(` 📄 ${path.join(outputDir, `${pascalCaseName}Page.ts`)}`);
|
|
1920
|
+
logger.newLine();
|
|
1921
|
+
logger.info('下一步:');
|
|
1922
|
+
logger.info(` 1. 查看生成的 Mock 代码: ${path.join(outputDir, `${mockFileName}.ts`)}`);
|
|
1923
|
+
logger.info(` 2. 查看生成的测试用例: ${path.join(outputDir, `${pascalCaseName}.spec.ts`)}`);
|
|
1924
|
+
logger.info(` 3. 查看生成的 POM 代码: ${path.join(outputDir, `${pascalCaseName}Page.ts`)}`);
|
|
1925
|
+
logger.info(` 4. 运行测试: npx playwright test ${path.join(outputDir, `${pascalCaseName}.spec.ts`)}`);
|
|
1926
|
+
logger.newLine();
|
|
1927
|
+
}
|
|
1928
|
+
catch (error) {
|
|
1929
|
+
logger.failSpinner('录制失败');
|
|
1930
|
+
throw error;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* 从 URL 提取页面名称
|
|
1935
|
+
*/
|
|
1936
|
+
function extractPageName(url) {
|
|
1937
|
+
try {
|
|
1938
|
+
const urlObj = new URL(url);
|
|
1939
|
+
const pathname = urlObj.pathname;
|
|
1940
|
+
// 提取最后一段路径作为页面名称
|
|
1941
|
+
const segments = pathname.split('/').filter((s) => s);
|
|
1942
|
+
const lastSegment = segments[segments.length - 1];
|
|
1943
|
+
// 如果最后一段是文件名(有扩展名),去掉扩展名
|
|
1944
|
+
if (lastSegment && lastSegment.includes('.')) {
|
|
1945
|
+
return lastSegment.split('.')[0];
|
|
1946
|
+
}
|
|
1947
|
+
// 否则返回最后一段,如果为空则返回 'page'
|
|
1948
|
+
return lastSegment || 'page';
|
|
1949
|
+
}
|
|
1950
|
+
catch {
|
|
1951
|
+
return 'page';
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* 将字符串转换为 PascalCase 格式
|
|
1956
|
+
* @example
|
|
1957
|
+
* toPascalCase('user-profile') // 'UserProfile'
|
|
1958
|
+
* toPascalCase('my_page') // 'MyPage'
|
|
1959
|
+
* toPascalCase('login') // 'Login'
|
|
1960
|
+
*/
|
|
1961
|
+
function toPascalCase(str) {
|
|
1962
|
+
return str
|
|
1963
|
+
.split(/[-_\s]+/) // 按短横线、下划线、空格分割
|
|
1964
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
1965
|
+
.join('');
|
|
1966
|
+
}
|
|
1967
|
+
|
|
510
1968
|
/**
|
|
511
1969
|
* 生成命令
|
|
512
1970
|
*/
|
|
@@ -530,28 +1988,38 @@ async function generateCommand(options) {
|
|
|
530
1988
|
mode = 'hybrid';
|
|
531
1989
|
}
|
|
532
1990
|
logger.info(`生成模式: ${mode}`);
|
|
533
|
-
|
|
1991
|
+
logger.newLine();
|
|
1992
|
+
// 实现不同的生成模式
|
|
534
1993
|
switch (mode) {
|
|
535
|
-
case 'record':
|
|
536
|
-
|
|
537
|
-
|
|
1994
|
+
case 'record': {
|
|
1995
|
+
// 录制模式 - 调用 recordCommand
|
|
1996
|
+
const recordOptions = {
|
|
1997
|
+
url: options.url,
|
|
1998
|
+
output: options.output,
|
|
1999
|
+
interactive: options.interactive,
|
|
2000
|
+
};
|
|
2001
|
+
await recordCommand(recordOptions);
|
|
538
2002
|
break;
|
|
2003
|
+
}
|
|
539
2004
|
case 'analyze':
|
|
540
2005
|
logger.info('代码分析模式 - 开发中...');
|
|
541
2006
|
logger.info(`将分析: ${options.page}`);
|
|
2007
|
+
logger.newLine();
|
|
2008
|
+
logger.warn('此功能正在开发中,敬请期待!');
|
|
542
2009
|
break;
|
|
543
2010
|
case 'describe':
|
|
544
2011
|
logger.info('描述生成模式 - 开发中...');
|
|
545
2012
|
logger.info(`描述: ${options.desc}`);
|
|
2013
|
+
logger.newLine();
|
|
2014
|
+
logger.warn('此功能正在开发中,敬请期待!');
|
|
546
2015
|
break;
|
|
547
2016
|
case 'hybrid':
|
|
548
2017
|
logger.info('混合模式 - 开发中...');
|
|
549
2018
|
logger.info('将进入交互式引导...');
|
|
2019
|
+
logger.newLine();
|
|
2020
|
+
logger.warn('此功能正在开发中,敬请期待!');
|
|
550
2021
|
break;
|
|
551
2022
|
}
|
|
552
|
-
logger.newLine();
|
|
553
|
-
logger.warn('此功能正在开发中,敬请期待!');
|
|
554
|
-
logger.info('当前版本仅支持 smart-test init 命令');
|
|
555
2023
|
}
|
|
556
2024
|
|
|
557
2025
|
/**
|
|
@@ -584,16 +2052,17 @@ function createCLI() {
|
|
|
584
2052
|
.action(withErrorHandling(async (options) => {
|
|
585
2053
|
await generateCommand(options);
|
|
586
2054
|
}));
|
|
587
|
-
// ========== record
|
|
2055
|
+
// ========== record 命令 ==========
|
|
588
2056
|
program
|
|
589
2057
|
.command('record')
|
|
590
|
-
.description('
|
|
2058
|
+
.description('录制模式生成测试')
|
|
591
2059
|
.requiredOption('-u, --url <url>', '页面 URL')
|
|
592
2060
|
.option('-o, --output <dir>', '输出目录')
|
|
593
2061
|
.option('--manual', '手动操作模式')
|
|
594
2062
|
.option('--interactive', '交互式引导')
|
|
2063
|
+
.option('--timeout <seconds>', '录制超时时间(秒)', '60')
|
|
595
2064
|
.action(withErrorHandling(async (options) => {
|
|
596
|
-
await
|
|
2065
|
+
await recordCommand(options);
|
|
597
2066
|
}));
|
|
598
2067
|
// ========== config 命令 ==========
|
|
599
2068
|
program
|
|
@@ -602,7 +2071,7 @@ function createCLI() {
|
|
|
602
2071
|
.action(withErrorHandling(async () => {
|
|
603
2072
|
const { configLoader } = await Promise.resolve().then(function () { return configLoader$1; });
|
|
604
2073
|
const config = await configLoader.load();
|
|
605
|
-
logger.title('当前配置');
|
|
2074
|
+
logger.title('[smart-test] 当前配置');
|
|
606
2075
|
console.log(JSON.stringify(config, null, 2));
|
|
607
2076
|
}));
|
|
608
2077
|
return program;
|