@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/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: 'kebab-case (推荐)', value: 'kebab-case' },
324
+ { name: 'PascalCase (推荐)', value: 'PascalCase' },
323
325
  { name: 'camelCase', value: 'camelCase' },
324
- { name: 'snake_case', value: 'snake_case' },
326
+ { name: 'kebab-case', value: 'kebab-case' },
325
327
  ],
326
- default: 'kebab-case',
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: 'kebab-case',
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
- // TODO: 实现不同的生成模式
1991
+ logger.newLine();
1992
+ // 实现不同的生成模式
534
1993
  switch (mode) {
535
- case 'record':
536
- logger.info('录制模式 - 开发中...');
537
- logger.info(`将录制: ${options.url}`);
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 命令(generate 的别名)==========
2055
+ // ========== record 命令 ==========
588
2056
  program
589
2057
  .command('record')
590
- .description('录制模式生成测试(generate --url 的别名)')
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 generateCommand({ url: options.url, ...options });
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;