@be-link/smart-test 1.0.1-beta.1 → 1.0.1-beta.3

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
  * 日志级别
@@ -507,6 +509,1391 @@ 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
+ * 保存 Mock 代码
903
+ */
904
+ async saveMockCode(requests, outputPath, pageName) {
905
+ const mockCode = this.generateMockCode(requests, pageName);
906
+ const mockPath = path.join(outputPath, `mock-${pageName}.ts`);
907
+ await fs.mkdir(outputPath, { recursive: true });
908
+ await fs.writeFile(mockPath, mockCode, 'utf-8');
909
+ logger.success(`Mock 代码已保存: ${mockPath}`);
910
+ return mockPath;
911
+ }
912
+ }
913
+
914
+ /**
915
+ * AI 测试用例生成器
916
+ */
917
+ class TestGenerator {
918
+ constructor(config) {
919
+ this.config = config;
920
+ this.openai = null;
921
+ // 初始化 OpenAI 客户端
922
+ if (config.ai?.apiKey) {
923
+ this.openai = new OpenAI({
924
+ apiKey: config.ai.apiKey,
925
+ baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
926
+ timeout: config.ai.timeout || 60000,
927
+ });
928
+ }
929
+ }
930
+ /**
931
+ * 从录制数据生成测试用例
932
+ */
933
+ async generateTest(url, pageName, requests, mockFileName) {
934
+ if (!this.openai) {
935
+ logger.warn('未配置 AI API Key,跳过测试用例生成');
936
+ return this.generateBasicTest(url, pageName, mockFileName);
937
+ }
938
+ try {
939
+ logger.debug('准备 AI 提示词...');
940
+ // 准备 AI 提示词
941
+ const prompt = this.buildPrompt(url, pageName, requests, mockFileName);
942
+ logger.debug('调用 AI 生成测试用例...');
943
+ // 调用 AI 生成测试用例
944
+ const response = await this.openai.chat.completions.create({
945
+ model: this.config.ai?.model || 'qwen-vl-plus',
946
+ messages: [
947
+ {
948
+ role: 'system',
949
+ content: '你是一个专业的测试工程师,擅长使用 Playwright 编写 E2E 测试。请根据提供的页面信息和网络请求记录,生成高质量的测试用例代码。',
950
+ },
951
+ {
952
+ role: 'user',
953
+ content: prompt,
954
+ },
955
+ ],
956
+ temperature: 0.3,
957
+ });
958
+ const generatedCode = response.choices[0]?.message?.content || '';
959
+ // 提取代码块
960
+ const codeMatch = generatedCode.match(/```(?:typescript|ts)?\n([\s\S]*?)```/);
961
+ if (codeMatch) {
962
+ return codeMatch[1].trim();
963
+ }
964
+ return generatedCode.trim();
965
+ }
966
+ catch (error) {
967
+ logger.warn(`AI 生成测试失败: ${error instanceof Error ? error.message : String(error)}`);
968
+ logger.info('将生成基础测试模板');
969
+ return this.generateBasicTest(url, pageName, mockFileName);
970
+ }
971
+ }
972
+ /**
973
+ * 构建 AI 提示词
974
+ */
975
+ buildPrompt(url, pageName, requests, mockFileName) {
976
+ // 统计请求信息
977
+ const requestSummary = this.summarizeRequests(requests);
978
+ return `
979
+ 请为以下页面生成 Playwright 测试用例:
980
+
981
+ ## 页面信息
982
+ - URL: ${url}
983
+ - 页面名称: ${pageName}
984
+
985
+ ## 网络请求统计
986
+ ${requestSummary}
987
+
988
+ ## 要求
989
+ 1. 使用 TypeScript 语法
990
+ 2. 导入 Mock 函数: import { mock${this.capitalize(pageName)}Apis } from './${mockFileName}'
991
+ 3. 在测试开始前调用 Mock 函数
992
+ 4. 包含基础的页面加载测试
993
+ 5. 根据请求类型,生成合适的测试场景(如表单提交、数据加载等)
994
+ 6. 使用 Playwright 的最佳实践
995
+ 7. 包含有意义的断言
996
+ 8. 代码简洁清晰,有适当的注释
997
+
998
+ 请直接返回测试代码,不需要额外的说明。
999
+ `;
1000
+ }
1001
+ /**
1002
+ * 总结请求信息
1003
+ */
1004
+ summarizeRequests(requests) {
1005
+ const summary = [];
1006
+ // 按方法统计
1007
+ const methodCount = {};
1008
+ requests.forEach((req) => {
1009
+ methodCount[req.method] = (methodCount[req.method] || 0) + 1;
1010
+ });
1011
+ summary.push('请求统计:');
1012
+ Object.entries(methodCount).forEach(([method, count]) => {
1013
+ summary.push(` - ${method}: ${count} 个请求`);
1014
+ });
1015
+ // 列出主要的 API 端点
1016
+ summary.push('\n主要 API 端点:');
1017
+ requests
1018
+ .filter((req) => req.response)
1019
+ .slice(0, 10) // 最多列出 10 个
1020
+ .forEach((req) => {
1021
+ const urlObj = new URL(req.url);
1022
+ summary.push(` - ${req.method} ${urlObj.pathname} (${req.response?.status})`);
1023
+ });
1024
+ return summary.join('\n');
1025
+ }
1026
+ /**
1027
+ * 生成基础测试模板(不使用 AI)
1028
+ */
1029
+ generateBasicTest(url, pageName, mockFileName) {
1030
+ const capitalizedName = this.capitalize(pageName);
1031
+ return `import { test, expect } from '@playwright/test';
1032
+ import { mock${capitalizedName}Apis } from './${mockFileName}';
1033
+
1034
+ test.describe('${capitalizedName} 页面测试', () => {
1035
+ test.beforeEach(async ({ page }) => {
1036
+ // 设置 Mock 数据
1037
+ await mock${capitalizedName}Apis(page);
1038
+ });
1039
+
1040
+ test('应该成功加载页面', async ({ page }) => {
1041
+ // 访问页面
1042
+ await page.goto('${url}');
1043
+
1044
+ // 等待页面加载
1045
+ await page.waitForLoadState('networkidle');
1046
+
1047
+ // 验证页面标题或关键元素
1048
+ await expect(page).toHaveURL('${url}');
1049
+ });
1050
+
1051
+ // TODO: 添加更多测试用例
1052
+ });
1053
+ `;
1054
+ }
1055
+ /**
1056
+ * 保存测试文件
1057
+ */
1058
+ async saveTest(testCode, outputPath, pageName) {
1059
+ const testPath = path.join(outputPath, `${pageName}.spec.ts`);
1060
+ await fs.mkdir(outputPath, { recursive: true });
1061
+ await fs.writeFile(testPath, testCode, 'utf-8');
1062
+ logger.success(`测试文件已保存: ${testPath}`);
1063
+ return testPath;
1064
+ }
1065
+ /**
1066
+ * 首字母大写
1067
+ */
1068
+ capitalize(str) {
1069
+ return str.charAt(0).toUpperCase() + str.slice(1);
1070
+ }
1071
+ }
1072
+
1073
+ /**
1074
+ * Page Object Model 生成器
1075
+ */
1076
+ class POMGenerator {
1077
+ constructor(config) {
1078
+ this.config = config;
1079
+ this.openai = null;
1080
+ // 初始化 OpenAI 客户端
1081
+ if (config.ai?.apiKey) {
1082
+ this.openai = new OpenAI({
1083
+ apiKey: config.ai.apiKey,
1084
+ baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
1085
+ timeout: config.ai.timeout || 60000,
1086
+ });
1087
+ }
1088
+ }
1089
+ /**
1090
+ * 从页面提取元素信息
1091
+ */
1092
+ async extractElements(page) {
1093
+ logger.debug('提取页面元素信息...');
1094
+ const elements = await page.evaluate(() => {
1095
+ // 辅助函数:生成选择器
1096
+ function generateSelector(el) {
1097
+ // 优先使用 id
1098
+ if (el.id) {
1099
+ return `#${el.id}`;
1100
+ }
1101
+ // 使用 name 属性
1102
+ const name = el.getAttribute('name');
1103
+ if (name) {
1104
+ return `[name="${name}"]`;
1105
+ }
1106
+ // 使用 data-testid
1107
+ const testId = el.getAttribute('data-testid');
1108
+ if (testId) {
1109
+ return `[data-testid="${testId}"]`;
1110
+ }
1111
+ // 使用类名(取第一个)
1112
+ if (el.className && typeof el.className === 'string') {
1113
+ const firstClass = el.className.split(' ')[0];
1114
+ if (firstClass) {
1115
+ return `.${firstClass}`;
1116
+ }
1117
+ }
1118
+ // 使用标签名
1119
+ return el.tagName.toLowerCase();
1120
+ }
1121
+ // 辅助函数:推断描述
1122
+ function inferDescription(el) {
1123
+ const tag = el.tagName.toLowerCase();
1124
+ // 从文本内容推断
1125
+ const text = el.textContent?.trim();
1126
+ if (text && text.length < 30) {
1127
+ return text.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]+/g, ' ').trim();
1128
+ }
1129
+ // 从属性推断
1130
+ const placeholder = el.getAttribute('placeholder');
1131
+ if (placeholder) {
1132
+ return placeholder;
1133
+ }
1134
+ const name = el.getAttribute('name');
1135
+ if (name) {
1136
+ return name;
1137
+ }
1138
+ const id = el.getAttribute('id');
1139
+ if (id) {
1140
+ return id;
1141
+ }
1142
+ const type = el.getAttribute('type');
1143
+ if (type) {
1144
+ return `${type} ${tag}`;
1145
+ }
1146
+ return tag;
1147
+ }
1148
+ // 辅助函数:获取属性
1149
+ function getAttributes(el) {
1150
+ const attrs = {};
1151
+ ['id', 'name', 'type', 'placeholder', 'value', 'href', 'role', 'data-testid'].forEach((attrName) => {
1152
+ const value = el.getAttribute(attrName);
1153
+ if (value) {
1154
+ attrs[attrName] = value;
1155
+ }
1156
+ });
1157
+ return attrs;
1158
+ }
1159
+ const results = [];
1160
+ // 提取输入框
1161
+ document.querySelectorAll('input, textarea').forEach((el) => {
1162
+ const input = el;
1163
+ results.push({
1164
+ type: input.tagName.toLowerCase() === 'textarea' ? 'textarea' : 'input',
1165
+ selector: generateSelector(input),
1166
+ description: inferDescription(input),
1167
+ attributes: getAttributes(input),
1168
+ text: input.value,
1169
+ });
1170
+ });
1171
+ // 提取按钮
1172
+ document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el) => {
1173
+ const button = el;
1174
+ results.push({
1175
+ type: 'button',
1176
+ selector: generateSelector(button),
1177
+ description: inferDescription(button),
1178
+ attributes: getAttributes(button),
1179
+ text: button.textContent?.trim() || '',
1180
+ });
1181
+ });
1182
+ // 提取链接
1183
+ document.querySelectorAll('a[href]').forEach((el) => {
1184
+ const link = el;
1185
+ results.push({
1186
+ type: 'link',
1187
+ selector: generateSelector(link),
1188
+ description: inferDescription(link),
1189
+ attributes: getAttributes(link),
1190
+ text: link.textContent?.trim() || '',
1191
+ });
1192
+ });
1193
+ // 提取下拉框
1194
+ document.querySelectorAll('select').forEach((el) => {
1195
+ const select = el;
1196
+ results.push({
1197
+ type: 'select',
1198
+ selector: generateSelector(select),
1199
+ description: inferDescription(select),
1200
+ attributes: getAttributes(select),
1201
+ text: select.selectedOptions[0]?.text || '',
1202
+ });
1203
+ });
1204
+ return results;
1205
+ });
1206
+ logger.debug(`提取到 ${elements.length} 个页面元素`);
1207
+ return elements;
1208
+ }
1209
+ /**
1210
+ * 生成 Page Object 代码
1211
+ */
1212
+ async generatePOM(pageName, pageUrl, elements) {
1213
+ const capitalizedName = this.capitalize(pageName);
1214
+ const className = `${capitalizedName}Page`;
1215
+ // 如果没有元素,生成基础 POM
1216
+ if (elements.length === 0) {
1217
+ return this.generateBasicPOM(className, pageUrl);
1218
+ }
1219
+ // 使用 AI 优化元素命名(如果可用)
1220
+ let optimizedElements = elements;
1221
+ if (this.openai) {
1222
+ try {
1223
+ optimizedElements = await this.optimizeElementNames(elements);
1224
+ }
1225
+ catch (error) {
1226
+ logger.warn(`AI 优化元素命名失败: ${error instanceof Error ? error.message : String(error)}`);
1227
+ }
1228
+ }
1229
+ // 生成代码
1230
+ let code = `import type { Page, Locator } from '@playwright/test';\n\n`;
1231
+ code += `/**\n`;
1232
+ code += ` * ${capitalizedName} 页面 Page Object\n`;
1233
+ code += ` */\n`;
1234
+ code += `export class ${className} {\n`;
1235
+ code += ` readonly url = '${pageUrl}';\n\n`;
1236
+ // 生成元素定位器声明
1237
+ code += ` // 页面元素\n`;
1238
+ optimizedElements.forEach((el) => {
1239
+ const propertyName = this.toPropertyName(el.description);
1240
+ code += ` readonly ${propertyName}: Locator;\n`;
1241
+ });
1242
+ code += `\n`;
1243
+ code += ` constructor(private page: Page) {\n`;
1244
+ // 生成元素初始化
1245
+ optimizedElements.forEach((el) => {
1246
+ const propertyName = this.toPropertyName(el.description);
1247
+ code += ` this.${propertyName} = page.locator('${el.selector}');\n`;
1248
+ });
1249
+ code += ` }\n\n`;
1250
+ // 生成基础方法
1251
+ code += ` /**\n`;
1252
+ code += ` * 访问页面\n`;
1253
+ code += ` */\n`;
1254
+ code += ` async goto() {\n`;
1255
+ code += ` await this.page.goto(this.url);\n`;
1256
+ code += ` }\n\n`;
1257
+ code += ` /**\n`;
1258
+ code += ` * 等待页面加载完成\n`;
1259
+ code += ` */\n`;
1260
+ code += ` async waitForLoad() {\n`;
1261
+ code += ` await this.page.waitForLoadState('networkidle');\n`;
1262
+ code += ` }\n`;
1263
+ code += `}\n`;
1264
+ return code;
1265
+ }
1266
+ /**
1267
+ * 使用 AI 优化元素命名
1268
+ */
1269
+ async optimizeElementNames(elements) {
1270
+ if (!this.openai) {
1271
+ return elements;
1272
+ }
1273
+ logger.debug('使用 AI 优化元素命名...');
1274
+ const elementsSummary = elements
1275
+ .map((el, index) => {
1276
+ return `${index + 1}. ${el.type} - selector: ${el.selector}, text: "${el.text || ''}", name: "${el.attributes.name || ''}", id: "${el.attributes.id || ''}"`;
1277
+ })
1278
+ .join('\n');
1279
+ const prompt = `
1280
+ 请为以下页面元素生成语义化的属性名称(使用小驼峰命名法)。
1281
+
1282
+ 元素列表:
1283
+ ${elementsSummary}
1284
+
1285
+ 要求:
1286
+ 1. 属性名要体现元素的用途
1287
+ 2. 使用英文,小驼峰命名法
1288
+ 3. 简洁清晰
1289
+ 4. 对于按钮、链接,从文本内容推断用途
1290
+ 5. 对于输入框,从 name/id/placeholder 推断用途
1291
+
1292
+ 请以 JSON 数组格式返回,每个元素包含 index 和 propertyName:
1293
+ [{"index": 1, "propertyName": "usernameInput"}, ...]
1294
+ `;
1295
+ try {
1296
+ const response = await this.openai.chat.completions.create({
1297
+ model: this.config.ai?.model || 'qwen-vl-plus',
1298
+ messages: [
1299
+ {
1300
+ role: 'system',
1301
+ content: '你是一个专业的前端工程师,擅长为 UI 元素命名。',
1302
+ },
1303
+ {
1304
+ role: 'user',
1305
+ content: prompt,
1306
+ },
1307
+ ],
1308
+ temperature: 0.3,
1309
+ });
1310
+ const result = response.choices[0]?.message?.content || '';
1311
+ // 提取 JSON
1312
+ const jsonMatch = result.match(/\[[\s\S]*\]/);
1313
+ if (jsonMatch) {
1314
+ const naming = JSON.parse(jsonMatch[0]);
1315
+ // 应用命名
1316
+ return elements.map((el, index) => {
1317
+ const nameData = naming.find((n) => n.index === index + 1);
1318
+ if (nameData && nameData.propertyName) {
1319
+ return { ...el, description: nameData.propertyName };
1320
+ }
1321
+ return el;
1322
+ });
1323
+ }
1324
+ }
1325
+ catch (error) {
1326
+ logger.debug(`AI 命名解析失败: ${error instanceof Error ? error.message : String(error)}`);
1327
+ }
1328
+ return elements;
1329
+ }
1330
+ /**
1331
+ * 生成基础 POM(无元素)
1332
+ */
1333
+ generateBasicPOM(className, pageUrl) {
1334
+ return `import type { Page } from '@playwright/test';
1335
+
1336
+ /**
1337
+ * ${className} Page Object
1338
+ */
1339
+ export class ${className} {
1340
+ readonly url = '${pageUrl}';
1341
+
1342
+ constructor(private page: Page) {}
1343
+
1344
+ /**
1345
+ * 访问页面
1346
+ */
1347
+ async goto() {
1348
+ await this.page.goto(this.url);
1349
+ }
1350
+
1351
+ /**
1352
+ * 等待页面加载完成
1353
+ */
1354
+ async waitForLoad() {
1355
+ await this.page.waitForLoadState('networkidle');
1356
+ }
1357
+ }
1358
+ `;
1359
+ }
1360
+ /**
1361
+ * 将描述转换为属性名
1362
+ */
1363
+ toPropertyName(description) {
1364
+ // 移除特殊字符,转换为小驼峰
1365
+ return description
1366
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
1367
+ .trim()
1368
+ .split(' ')
1369
+ .map((word, index) => {
1370
+ word = word.toLowerCase();
1371
+ if (index > 0) {
1372
+ word = word.charAt(0).toUpperCase() + word.slice(1);
1373
+ }
1374
+ return word;
1375
+ })
1376
+ .join('');
1377
+ }
1378
+ /**
1379
+ * 保存 POM 文件
1380
+ */
1381
+ async savePOM(pomCode, outputPath, pageName) {
1382
+ const pomPath = path.join(outputPath, `${pageName}-page.po.ts`);
1383
+ await fs.mkdir(outputPath, { recursive: true });
1384
+ await fs.writeFile(pomPath, pomCode, 'utf-8');
1385
+ logger.success(`POM 文件已保存: ${pomPath}`);
1386
+ return pomPath;
1387
+ }
1388
+ /**
1389
+ * 首字母大写
1390
+ */
1391
+ capitalize(str) {
1392
+ return str.charAt(0).toUpperCase() + str.slice(1);
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * AI 交互式引导
1398
+ */
1399
+ class InteractiveGuide {
1400
+ constructor(page, config) {
1401
+ this.page = page;
1402
+ this.config = config;
1403
+ this.openai = null;
1404
+ this.pomGenerator = new POMGenerator(config);
1405
+ this.context = {
1406
+ url: page.url(),
1407
+ elements: [],
1408
+ executedActions: [],
1409
+ currentStep: 0,
1410
+ };
1411
+ // 初始化 OpenAI 客户端
1412
+ if (config.ai?.apiKey) {
1413
+ this.openai = new OpenAI({
1414
+ apiKey: config.ai.apiKey,
1415
+ baseURL: config.ai.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
1416
+ timeout: config.ai.timeout || 60000,
1417
+ });
1418
+ }
1419
+ }
1420
+ /**
1421
+ * 启动交互式引导
1422
+ */
1423
+ async start() {
1424
+ logger.newLine();
1425
+ logger.ai('🤖 AI 交互式引导模式启动');
1426
+ logger.info('AI 将分析页面并为您提供测试操作建议喵~');
1427
+ logger.newLine();
1428
+ try {
1429
+ // 主循环
1430
+ while (true) {
1431
+ // 1. 分析页面
1432
+ await this.analyzePageContext();
1433
+ // 2. 生成建议
1434
+ const suggestions = await this.generateSuggestions();
1435
+ if (suggestions.length === 0) {
1436
+ logger.warn('没有更多的操作建议了喵~');
1437
+ break;
1438
+ }
1439
+ // 3. 展示建议并获取用户选择
1440
+ const selectedAction = await this.displaySuggestions(suggestions);
1441
+ // 4. 检查是否结束
1442
+ if (selectedAction.type === 'finish') {
1443
+ logger.success('录制已结束喵~');
1444
+ break;
1445
+ }
1446
+ // 5. 执行操作
1447
+ await this.executeAction(selectedAction);
1448
+ // 6. 记录操作
1449
+ this.context.executedActions.push(selectedAction);
1450
+ this.context.currentStep++;
1451
+ // 等待页面可能的状态变化
1452
+ await this.page.waitForTimeout(1000);
1453
+ }
1454
+ }
1455
+ catch (error) {
1456
+ logger.error(`交互式引导出错: ${error instanceof Error ? error.message : String(error)}`);
1457
+ throw error;
1458
+ }
1459
+ }
1460
+ /**
1461
+ * 分析页面当前上下文
1462
+ */
1463
+ async analyzePageContext() {
1464
+ logger.startSpinner('正在分析页面结构...');
1465
+ try {
1466
+ // 提取页面元素
1467
+ this.context.elements = await this.pomGenerator.extractElements(this.page);
1468
+ this.context.url = this.page.url();
1469
+ logger.succeedSpinner(`发现 ${this.context.elements.length} 个可操作元素`);
1470
+ }
1471
+ catch (error) {
1472
+ logger.failSpinner('页面分析失败');
1473
+ throw error;
1474
+ }
1475
+ }
1476
+ /**
1477
+ * 生成操作建议
1478
+ */
1479
+ async generateSuggestions() {
1480
+ // 如果没有 AI,返回基础建议
1481
+ if (!this.openai) {
1482
+ return this.generateBasicSuggestions();
1483
+ }
1484
+ logger.startSpinner('AI 正在生成测试建议...');
1485
+ try {
1486
+ const prompt = this.buildSuggestionsPrompt();
1487
+ const response = await this.openai.chat.completions.create({
1488
+ model: this.config.ai?.model || 'qwen-vl-plus',
1489
+ messages: [
1490
+ {
1491
+ role: 'system',
1492
+ content: '你是一个专业的测试工程师。根据页面元素和已执行的操作,建议接下来应该执行的测试操作。' +
1493
+ '请以 JSON 数组格式返回建议,每个建议包含:type(操作类型)、elementIndex(元素索引,-1 表示无需元素)、' +
1494
+ 'description(操作描述)、value(操作值,可选)、priority(优先级 1-5)、reason(理由)。' +
1495
+ '操作类型包括:fill(填写)、click(点击)、select(选择)、verify(验证)、wait(等待)、finish(结束)。',
1496
+ },
1497
+ {
1498
+ role: 'user',
1499
+ content: prompt,
1500
+ },
1501
+ ],
1502
+ temperature: 0.5,
1503
+ response_format: { type: 'json_object' },
1504
+ });
1505
+ const content = response.choices[0]?.message?.content || '{}';
1506
+ const result = JSON.parse(content);
1507
+ // 解析 AI 返回的建议
1508
+ const suggestions = (result.suggestions || []).map((s, index) => ({
1509
+ id: `ai-${this.context.currentStep}-${index}`,
1510
+ type: s.type || 'click',
1511
+ element: s.elementIndex >= 0 ? this.context.elements[s.elementIndex] : undefined,
1512
+ description: s.description || '未知操作',
1513
+ value: s.value,
1514
+ priority: s.priority || 3,
1515
+ reason: s.reason,
1516
+ }));
1517
+ // 始终添加"结束录制"选项
1518
+ suggestions.push({
1519
+ id: 'finish',
1520
+ type: 'finish',
1521
+ description: '结束录制',
1522
+ priority: 1,
1523
+ });
1524
+ logger.succeedSpinner(`生成了 ${suggestions.length - 1} 个测试建议`);
1525
+ return suggestions.sort((a, b) => b.priority - a.priority);
1526
+ }
1527
+ catch (error) {
1528
+ logger.failSpinner('AI 建议生成失败');
1529
+ logger.warn(`将使用基础建议: ${error instanceof Error ? error.message : String(error)}`);
1530
+ return this.generateBasicSuggestions();
1531
+ }
1532
+ }
1533
+ /**
1534
+ * 构建 AI 提示词
1535
+ */
1536
+ buildSuggestionsPrompt() {
1537
+ const elementsInfo = this.context.elements
1538
+ .map((el, idx) => `${idx}. [${el.type}] ${el.description || '未命名元素'} (${el.selector})${el.text ? ` - 文本: "${el.text}"` : ''}`)
1539
+ .join('\n');
1540
+ const actionsInfo = this.context.executedActions.length > 0
1541
+ ? this.context.executedActions
1542
+ .map((a, idx) => `${idx + 1}. ${a.description}${a.value ? ` (值: ${a.value})` : ''}`)
1543
+ .join('\n')
1544
+ : '无';
1545
+ return `
1546
+ 当前页面 URL: ${this.context.url}
1547
+ 当前步骤: ${this.context.currentStep + 1}
1548
+
1549
+ 可操作元素列表:
1550
+ ${elementsInfo}
1551
+
1552
+ 已执行的操作:
1553
+ ${actionsInfo}
1554
+
1555
+ 请分析当前页面状态,建议接下来应该执行的测试操作。
1556
+ 要求:
1557
+ 1. 建议应该符合真实的测试场景(如:填写表单 → 点击提交 → 验证结果)
1558
+ 2. 不要重复已执行的操作
1559
+ 3. 优先建议高价值的测试操作
1560
+ 4. 每次建议 2-4 个操作
1561
+ 5. 如果已经完成了基本的测试流程,可以建议结束
1562
+
1563
+ 返回 JSON 格式: {"suggestions": [{"type": "...", "elementIndex": 0, "description": "...", "value": "...", "priority": 5, "reason": "..."}]}
1564
+ `;
1565
+ }
1566
+ /**
1567
+ * 生成基础建议(不使用 AI)
1568
+ */
1569
+ generateBasicSuggestions() {
1570
+ const suggestions = [];
1571
+ // 为每个未操作的元素生成建议
1572
+ const executedElements = new Set(this.context.executedActions.map((a) => a.element?.selector).filter(Boolean));
1573
+ this.context.elements
1574
+ .filter((el) => !executedElements.has(el.selector))
1575
+ .slice(0, 5) // 最多显示 5 个
1576
+ .forEach((el, index) => {
1577
+ const suggestion = {
1578
+ id: `basic-${this.context.currentStep}-${index}`,
1579
+ type: el.type === 'input' || el.type === 'textarea' ? 'fill' : 'click',
1580
+ element: el,
1581
+ description: this.getBasicSuggestionDescription(el),
1582
+ priority: 3,
1583
+ };
1584
+ if (suggestion.type === 'fill') {
1585
+ suggestion.value = `测试${el.description || '输入'}`;
1586
+ }
1587
+ suggestions.push(suggestion);
1588
+ });
1589
+ // 添加"结束录制"选项
1590
+ suggestions.push({
1591
+ id: 'finish',
1592
+ type: 'finish',
1593
+ description: '结束录制',
1594
+ priority: 1,
1595
+ });
1596
+ return suggestions;
1597
+ }
1598
+ /**
1599
+ * 获取基础建议描述
1600
+ */
1601
+ getBasicSuggestionDescription(element) {
1602
+ if (element.type === 'input' || element.type === 'textarea') {
1603
+ return `填写 "${element.description || element.selector}"`;
1604
+ }
1605
+ if (element.type === 'button') {
1606
+ return `点击按钮 "${element.description || element.text || element.selector}"`;
1607
+ }
1608
+ if (element.type === 'link') {
1609
+ return `点击链接 "${element.description || element.text || element.selector}"`;
1610
+ }
1611
+ if (element.type === 'select') {
1612
+ return `选择 "${element.description || element.selector}"`;
1613
+ }
1614
+ return `操作 "${element.description || element.selector}"`;
1615
+ }
1616
+ /**
1617
+ * 展示建议并获取用户选择
1618
+ */
1619
+ async displaySuggestions(suggestions) {
1620
+ logger.newLine();
1621
+ logger.info('📋 AI 建议的测试操作:');
1622
+ logger.newLine();
1623
+ // 构建选项列表
1624
+ const choices = suggestions.map((s) => {
1625
+ let name = `${this.getPriorityIcon(s.priority)} ${s.description}`;
1626
+ if (s.reason) {
1627
+ name += `\n 💡 ${s.reason}`;
1628
+ }
1629
+ if (s.value) {
1630
+ name += `\n ✏️ 值: "${s.value}"`;
1631
+ }
1632
+ return {
1633
+ name,
1634
+ value: s.id,
1635
+ short: s.description,
1636
+ };
1637
+ });
1638
+ // 让用户选择
1639
+ const answer = await inquirer.prompt([
1640
+ {
1641
+ type: 'list',
1642
+ name: 'action',
1643
+ message: '请选择要执行的操作:',
1644
+ choices,
1645
+ pageSize: 10,
1646
+ },
1647
+ ]);
1648
+ const selected = suggestions.find((s) => s.id === answer.action);
1649
+ if (!selected) {
1650
+ throw new Error('无效的选择');
1651
+ }
1652
+ // 如果是填写操作且没有预设值,询问用户输入
1653
+ if (selected.type === 'fill' && !selected.value) {
1654
+ const inputAnswer = await inquirer.prompt([
1655
+ {
1656
+ type: 'input',
1657
+ name: 'value',
1658
+ message: `请输入要填写的内容:`,
1659
+ default: selected.value || `测试${selected.element?.description || '输入'}`,
1660
+ },
1661
+ ]);
1662
+ selected.value = inputAnswer.value;
1663
+ }
1664
+ return selected;
1665
+ }
1666
+ /**
1667
+ * 获取优先级图标
1668
+ */
1669
+ getPriorityIcon(priority) {
1670
+ if (priority >= 5)
1671
+ return '⭐⭐⭐';
1672
+ if (priority >= 4)
1673
+ return '⭐⭐';
1674
+ if (priority >= 3)
1675
+ return '⭐';
1676
+ return '◦';
1677
+ }
1678
+ /**
1679
+ * 执行操作
1680
+ */
1681
+ async executeAction(action) {
1682
+ logger.newLine();
1683
+ logger.startSpinner(`执行操作: ${action.description}`);
1684
+ try {
1685
+ switch (action.type) {
1686
+ case 'fill':
1687
+ if (action.element && action.value) {
1688
+ await this.page.locator(action.element.selector).fill(action.value);
1689
+ logger.succeedSpinner(`已填写: "${action.value}"`);
1690
+ }
1691
+ break;
1692
+ case 'click':
1693
+ if (action.element) {
1694
+ await this.page.locator(action.element.selector).click();
1695
+ logger.succeedSpinner('点击成功');
1696
+ }
1697
+ break;
1698
+ case 'select':
1699
+ if (action.element && action.value) {
1700
+ await this.page.locator(action.element.selector).selectOption(action.value);
1701
+ logger.succeedSpinner(`已选择: "${action.value}"`);
1702
+ }
1703
+ break;
1704
+ case 'verify':
1705
+ if (action.element) {
1706
+ const isVisible = await this.page.locator(action.element.selector).isVisible();
1707
+ logger.succeedSpinner(`验证${isVisible ? '成功' : '失败'}`);
1708
+ }
1709
+ break;
1710
+ case 'wait':
1711
+ await this.page.waitForLoadState('networkidle');
1712
+ logger.succeedSpinner('等待完成');
1713
+ break;
1714
+ case 'navigate':
1715
+ if (action.value) {
1716
+ await this.page.goto(action.value);
1717
+ logger.succeedSpinner('导航成功');
1718
+ }
1719
+ break;
1720
+ case 'finish':
1721
+ // 不需要执行任何操作
1722
+ break;
1723
+ default:
1724
+ logger.warn(`未知的操作类型: ${action.type}`);
1725
+ }
1726
+ }
1727
+ catch (error) {
1728
+ logger.failSpinner(`操作失败: ${error instanceof Error ? error.message : String(error)}`);
1729
+ throw error;
1730
+ }
1731
+ }
1732
+ /**
1733
+ * 获取执行的操作历史
1734
+ */
1735
+ getExecutedActions() {
1736
+ return this.context.executedActions;
1737
+ }
1738
+ }
1739
+
1740
+ /**
1741
+ * 录制命令
1742
+ */
1743
+ async function recordCommand(options) {
1744
+ logger.title('[smart-test] 录制模式 - 生成测试代码');
1745
+ // 加载配置
1746
+ const config = await configLoader.load();
1747
+ // 设置默认值
1748
+ const recordOptions = {
1749
+ ...options,
1750
+ output: options.output || config.output?.helpersDir || './tests/helpers',
1751
+ browser: options.browser || config.playwright?.browser || 'chromium',
1752
+ viewport: options.viewport || config.playwright?.viewport,
1753
+ timeout: options.timeout || 60,
1754
+ };
1755
+ logger.info(`录制 URL: ${recordOptions.url}`);
1756
+ logger.info(`输出目录: ${recordOptions.output}`);
1757
+ logger.info(`浏览器: ${recordOptions.browser}`);
1758
+ // 创建录制器
1759
+ const recorder = new NetworkRecorder();
1760
+ try {
1761
+ // 启动录制
1762
+ await recorder.start(recordOptions);
1763
+ // 根据模式等待用户操作
1764
+ if (options.manual) {
1765
+ logger.newLine();
1766
+ logger.ai('手动操作模式');
1767
+ logger.info('🌐 浏览器已打开,请在浏览器中进行操作');
1768
+ logger.info('📹 所有网络请求都在录制中');
1769
+ logger.prompt('完成操作后,请按 Ctrl+C 停止录制');
1770
+ logger.newLine();
1771
+ // 等待用户操作
1772
+ await recorder.waitForUserInteraction(recordOptions.timeout);
1773
+ }
1774
+ else if (options.interactive) {
1775
+ const page = recorder.getPage();
1776
+ if (!page) {
1777
+ logger.error('无法获取页面实例');
1778
+ throw new Error('无法启动交互式引导模式');
1779
+ }
1780
+ // 启动 AI 交互式引导
1781
+ const interactiveGuide = new InteractiveGuide(page, config);
1782
+ await interactiveGuide.start();
1783
+ }
1784
+ else {
1785
+ logger.newLine();
1786
+ logger.ai('自动录制模式');
1787
+ logger.info('将自动在 5 秒后停止录制');
1788
+ logger.newLine();
1789
+ // 自动模式,等待几秒
1790
+ await new Promise((resolve) => setTimeout(resolve, 5000));
1791
+ }
1792
+ // 提取页面元素(在关闭浏览器前)
1793
+ logger.newLine();
1794
+ logger.startSpinner('正在分析页面结构...');
1795
+ const page = recorder.getPage();
1796
+ const pomGenerator = new POMGenerator(config);
1797
+ let pageElements = [];
1798
+ if (page) {
1799
+ try {
1800
+ pageElements = await pomGenerator.extractElements(page);
1801
+ logger.succeedSpinner(`页面结构分析完成,发现 ${pageElements.length} 个元素`);
1802
+ }
1803
+ catch (error) {
1804
+ logger.failSpinner('页面结构分析失败');
1805
+ logger.warn(`提取元素失败: ${error instanceof Error ? error.message : String(error)}`);
1806
+ }
1807
+ }
1808
+ else {
1809
+ logger.failSpinner('无法获取页面实例');
1810
+ }
1811
+ // 停止录制
1812
+ const requests = await recorder.stop();
1813
+ // 显示统计
1814
+ recorder.displayStats();
1815
+ if (requests.length === 0) {
1816
+ logger.warn('未捕获到任何请求');
1817
+ return;
1818
+ }
1819
+ // 过滤出有响应的请求
1820
+ const validRequests = requests.filter((r) => r.response);
1821
+ logger.info(`有效请求数: ${validRequests.length}`);
1822
+ if (validRequests.length === 0) {
1823
+ logger.warn('没有有效的请求(所有请求都缺少响应)');
1824
+ return;
1825
+ }
1826
+ // 生成文件
1827
+ logger.newLine();
1828
+ logger.startSpinner('正在生成 Mock 代码...');
1829
+ const converter = new HARConverter();
1830
+ // 提取页面名称
1831
+ const pageName = extractPageName(recordOptions.url);
1832
+ logger.debug(`页面名称: ${pageName}`);
1833
+ // 保存 HAR 文件
1834
+ const outputDir = path.resolve(process.cwd(), recordOptions.output);
1835
+ await converter.saveHAR(validRequests, outputDir);
1836
+ // 生成并保存 Mock 代码
1837
+ const mockFileName = `mock-${pageName}`;
1838
+ await converter.saveMockCode(validRequests, outputDir, pageName);
1839
+ logger.succeedSpinner('Mock 代码生成完成');
1840
+ // 生成测试用例
1841
+ logger.newLine();
1842
+ logger.startSpinner('正在生成测试用例...');
1843
+ const testGenerator = new TestGenerator(config);
1844
+ const testCode = await testGenerator.generateTest(recordOptions.url, pageName, validRequests, mockFileName);
1845
+ await testGenerator.saveTest(testCode, outputDir, pageName);
1846
+ logger.succeedSpinner('测试用例生成完成');
1847
+ // 生成 Page Object Model
1848
+ logger.newLine();
1849
+ logger.startSpinner('正在生成 Page Object Model...');
1850
+ const pomCode = await pomGenerator.generatePOM(pageName, recordOptions.url, pageElements);
1851
+ await pomGenerator.savePOM(pomCode, outputDir, pageName);
1852
+ logger.succeedSpinner('POM 代码生成完成');
1853
+ // 下一步提示
1854
+ logger.newLine();
1855
+ logger.success('✅ 录制完成!');
1856
+ logger.newLine();
1857
+ logger.info('生成的文件:');
1858
+ logger.info(` 📄 ${path.join(outputDir, 'recorded.har')}`);
1859
+ logger.info(` 📄 ${path.join(outputDir, `${mockFileName}.ts`)}`);
1860
+ logger.info(` 📄 ${path.join(outputDir, `${pageName}.spec.ts`)}`);
1861
+ logger.info(` 📄 ${path.join(outputDir, `${pageName}-page.po.ts`)}`);
1862
+ logger.newLine();
1863
+ logger.info('下一步:');
1864
+ logger.info(` 1. 查看生成的 Mock 代码: ${path.join(outputDir, `${mockFileName}.ts`)}`);
1865
+ logger.info(` 2. 查看生成的测试用例: ${path.join(outputDir, `${pageName}.spec.ts`)}`);
1866
+ logger.info(` 3. 查看生成的 POM 代码: ${path.join(outputDir, `${pageName}-page.po.ts`)}`);
1867
+ logger.info(` 4. 运行测试: npx playwright test ${path.join(outputDir, `${pageName}.spec.ts`)}`);
1868
+ logger.newLine();
1869
+ }
1870
+ catch (error) {
1871
+ logger.failSpinner('录制失败');
1872
+ throw error;
1873
+ }
1874
+ }
1875
+ /**
1876
+ * 从 URL 提取页面名称
1877
+ */
1878
+ function extractPageName(url) {
1879
+ try {
1880
+ const urlObj = new URL(url);
1881
+ const pathname = urlObj.pathname;
1882
+ // 提取最后一段路径作为页面名称
1883
+ const segments = pathname.split('/').filter((s) => s);
1884
+ const lastSegment = segments[segments.length - 1];
1885
+ // 如果最后一段是文件名(有扩展名),去掉扩展名
1886
+ if (lastSegment && lastSegment.includes('.')) {
1887
+ return lastSegment.split('.')[0];
1888
+ }
1889
+ // 否则返回最后一段,如果为空则返回 'page'
1890
+ return lastSegment || 'page';
1891
+ }
1892
+ catch {
1893
+ return 'page';
1894
+ }
1895
+ }
1896
+
510
1897
  /**
511
1898
  * 生成命令
512
1899
  */
@@ -530,28 +1917,38 @@ async function generateCommand(options) {
530
1917
  mode = 'hybrid';
531
1918
  }
532
1919
  logger.info(`生成模式: ${mode}`);
533
- // TODO: 实现不同的生成模式
1920
+ logger.newLine();
1921
+ // 实现不同的生成模式
534
1922
  switch (mode) {
535
- case 'record':
536
- logger.info('录制模式 - 开发中...');
537
- logger.info(`将录制: ${options.url}`);
1923
+ case 'record': {
1924
+ // 录制模式 - 调用 recordCommand
1925
+ const recordOptions = {
1926
+ url: options.url,
1927
+ output: options.output,
1928
+ interactive: options.interactive,
1929
+ };
1930
+ await recordCommand(recordOptions);
538
1931
  break;
1932
+ }
539
1933
  case 'analyze':
540
1934
  logger.info('代码分析模式 - 开发中...');
541
1935
  logger.info(`将分析: ${options.page}`);
1936
+ logger.newLine();
1937
+ logger.warn('此功能正在开发中,敬请期待!');
542
1938
  break;
543
1939
  case 'describe':
544
1940
  logger.info('描述生成模式 - 开发中...');
545
1941
  logger.info(`描述: ${options.desc}`);
1942
+ logger.newLine();
1943
+ logger.warn('此功能正在开发中,敬请期待!');
546
1944
  break;
547
1945
  case 'hybrid':
548
1946
  logger.info('混合模式 - 开发中...');
549
1947
  logger.info('将进入交互式引导...');
1948
+ logger.newLine();
1949
+ logger.warn('此功能正在开发中,敬请期待!');
550
1950
  break;
551
1951
  }
552
- logger.newLine();
553
- logger.warn('此功能正在开发中,敬请期待!');
554
- logger.info('当前版本仅支持 smart-test init 命令');
555
1952
  }
556
1953
 
557
1954
  /**
@@ -584,16 +1981,17 @@ function createCLI() {
584
1981
  .action(withErrorHandling(async (options) => {
585
1982
  await generateCommand(options);
586
1983
  }));
587
- // ========== record 命令(generate 的别名)==========
1984
+ // ========== record 命令 ==========
588
1985
  program
589
1986
  .command('record')
590
- .description('录制模式生成测试(generate --url 的别名)')
1987
+ .description('录制模式生成测试')
591
1988
  .requiredOption('-u, --url <url>', '页面 URL')
592
1989
  .option('-o, --output <dir>', '输出目录')
593
1990
  .option('--manual', '手动操作模式')
594
1991
  .option('--interactive', '交互式引导')
1992
+ .option('--timeout <seconds>', '录制超时时间(秒)', '60')
595
1993
  .action(withErrorHandling(async (options) => {
596
- await generateCommand({ url: options.url, ...options });
1994
+ await recordCommand(options);
597
1995
  }));
598
1996
  // ========== config 命令 ==========
599
1997
  program
@@ -602,7 +2000,7 @@ function createCLI() {
602
2000
  .action(withErrorHandling(async () => {
603
2001
  const { configLoader } = await Promise.resolve().then(function () { return configLoader$1; });
604
2002
  const config = await configLoader.load();
605
- logger.title('当前配置');
2003
+ logger.title('[smart-test] 当前配置');
606
2004
  console.log(JSON.stringify(config, null, 2));
607
2005
  }));
608
2006
  return program;