@epoint-testtech/ep-stage-skill 0.0.10 → 0.5.0-alpha.2

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.
Files changed (158) hide show
  1. package/SKILL.md +27 -27
  2. package/codex-skill/ep-stage/glue-create-project/SKILL.md +223 -223
  3. package/codex-skill/ep-stage/glue-generate-testcase/SKILL.md +275 -275
  4. package/codex-skill/ep-stage/glue-generate-testcase/examples/observable-testcase.json +40 -40
  5. package/codex-skill/ep-stage/glue-generate-testcase/references/testcase-schema.md +95 -95
  6. package/codex-skill/ep-stage/glue-run-test/SKILL.md +273 -296
  7. package/codex-skill/ep-stage/glue-run-test/references/ai-runtime-loop.md +30 -30
  8. package/codex-skill/ep-stage/glue-run-test/references/crud-pipeline.md +132 -132
  9. package/codex-skill/ep-stage/glue-run-test/references/gap-review-protocol.md +44 -44
  10. package/codex-skill/ep-stage/glue-run-test/references/harness-principles.md +46 -46
  11. package/codex-skill/ep-stage/glue-run-test/references/playwright-cli-odav.md +127 -166
  12. package/codex-skill/ep-stage/glue-run-test/scripts/generate-crud-spec.mjs +149 -149
  13. package/codex-skill/ep-stage/recording-to-glue/SKILL.md +28 -28
  14. package/codex-skill/ep-stage/scripts/validate-skill.mjs +95 -98
  15. package/dist/src/cli/generate-testcase.d.ts +0 -1
  16. package/dist/src/cli/generate-testcase.d.ts.map +1 -1
  17. package/dist/src/cli/generate-testcase.js +1 -6
  18. package/dist/src/cli/index.d.ts.map +1 -1
  19. package/dist/src/cli/index.js +14 -38
  20. package/dist/src/cli/run.d.ts +0 -2
  21. package/dist/src/cli/run.d.ts.map +1 -1
  22. package/dist/src/cli/run.js +21 -70
  23. package/dist/src/cli/spec.d.ts.map +1 -1
  24. package/dist/src/cli/spec.js +0 -9
  25. package/dist/src/contracts/glue-contract-v1.d.ts +0 -35
  26. package/dist/src/contracts/glue-contract-v1.d.ts.map +1 -1
  27. package/dist/src/extractors/code-list-json5.d.ts +1 -34
  28. package/dist/src/extractors/code-list-json5.d.ts.map +1 -1
  29. package/dist/src/extractors/code-list-json5.js +0 -1
  30. package/dist/src/extractors/html-page.d.ts +0 -9
  31. package/dist/src/extractors/html-page.d.ts.map +1 -1
  32. package/dist/src/extractors/html-page.js +2 -26
  33. package/dist/src/generators/agent-inferred-workflow-script.js +66 -66
  34. package/dist/src/generators/stage-skeleton-script.d.ts +0 -50
  35. package/dist/src/generators/stage-skeleton-script.d.ts.map +1 -1
  36. package/dist/src/generators/stage-skeleton-script.js +116 -449
  37. package/dist/src/runtime/ai-runtime-trace.d.ts.map +1 -1
  38. package/dist/src/runtime/ai-runtime-trace.js +0 -8
  39. package/dist/src/testcase/contract-testcase-assembler.d.ts +0 -28
  40. package/dist/src/testcase/contract-testcase-assembler.d.ts.map +1 -1
  41. package/dist/src/testcase/contract-testcase-assembler.js +75 -329
  42. package/dist/src/validation/contract.d.ts.map +1 -1
  43. package/dist/src/validation/contract.js +0 -29
  44. package/dist/test/cli/generate-testcase-json5.test.d.ts +2 -0
  45. package/dist/test/cli/generate-testcase-json5.test.d.ts.map +1 -0
  46. package/dist/test/cli/generate-testcase-json5.test.js +154 -0
  47. package/dist/test/cli/index.test.d.ts +2 -0
  48. package/dist/test/cli/index.test.d.ts.map +1 -0
  49. package/dist/test/cli/index.test.js +114 -0
  50. package/dist/test/cli/run.test.d.ts +2 -0
  51. package/dist/test/cli/run.test.d.ts.map +1 -0
  52. package/dist/test/cli/run.test.js +429 -0
  53. package/dist/test/cli/spec.test.d.ts +2 -0
  54. package/dist/test/cli/spec.test.d.ts.map +1 -0
  55. package/dist/test/cli/spec.test.js +112 -0
  56. package/dist/test/code-list-json5.test.d.ts +2 -0
  57. package/dist/test/code-list-json5.test.d.ts.map +1 -0
  58. package/dist/test/code-list-json5.test.js +84 -0
  59. package/dist/test/code-list.test.d.ts +2 -0
  60. package/dist/test/code-list.test.d.ts.map +1 -0
  61. package/dist/test/code-list.test.js +39 -0
  62. package/dist/test/gap-inference.test.d.ts +2 -0
  63. package/dist/test/gap-inference.test.d.ts.map +1 -0
  64. package/dist/test/gap-inference.test.js +597 -0
  65. package/dist/test/generalization.test.d.ts +2 -0
  66. package/dist/test/generalization.test.d.ts.map +1 -0
  67. package/dist/test/generalization.test.js +73 -0
  68. package/dist/test/material-inventory.test.d.ts +2 -0
  69. package/dist/test/material-inventory.test.d.ts.map +1 -0
  70. package/dist/test/material-inventory.test.js +155 -0
  71. package/dist/test/observable-chain.test.d.ts +2 -0
  72. package/dist/test/observable-chain.test.d.ts.map +1 -0
  73. package/dist/test/observable-chain.test.js +123 -0
  74. package/dist/test/page-structure.test.d.ts +2 -0
  75. package/dist/test/page-structure.test.d.ts.map +1 -0
  76. package/dist/test/page-structure.test.js +45 -0
  77. package/dist/test/runtime/ai-runtime-trace.test.d.ts +2 -0
  78. package/dist/test/runtime/ai-runtime-trace.test.d.ts.map +1 -0
  79. package/dist/test/runtime/ai-runtime-trace.test.js +78 -0
  80. package/dist/test/scenario-inference.test.d.ts +2 -0
  81. package/dist/test/scenario-inference.test.d.ts.map +1 -0
  82. package/dist/test/scenario-inference.test.js +73 -0
  83. package/dist/test/stage-context.test.d.ts +2 -0
  84. package/dist/test/stage-context.test.d.ts.map +1 -0
  85. package/dist/test/stage-context.test.js +395 -0
  86. package/dist/test/testcase/testcase-markdown.test.d.ts +2 -0
  87. package/dist/test/testcase/testcase-markdown.test.d.ts.map +1 -0
  88. package/dist/test/testcase/testcase-markdown.test.js +48 -0
  89. package/dist/test/testcase-generator.test.d.ts +2 -0
  90. package/dist/test/testcase-generator.test.d.ts.map +1 -0
  91. package/dist/test/testcase-generator.test.js +276 -0
  92. package/dist/test/testcase-skeleton.test.d.ts +2 -0
  93. package/dist/test/testcase-skeleton.test.d.ts.map +1 -0
  94. package/dist/test/testcase-skeleton.test.js +185 -0
  95. package/dist/test/testcase-spec-assembly.test.d.ts +2 -0
  96. package/dist/test/testcase-spec-assembly.test.d.ts.map +1 -0
  97. package/dist/test/testcase-spec-assembly.test.js +105 -0
  98. package/dist/test/util/credentials.test.d.ts +2 -0
  99. package/dist/test/util/credentials.test.d.ts.map +1 -0
  100. package/dist/test/util/credentials.test.js +64 -0
  101. package/dist/test/util/i18n-testcase.test.d.ts +2 -0
  102. package/dist/test/util/i18n-testcase.test.d.ts.map +1 -0
  103. package/dist/test/util/i18n-testcase.test.js +119 -0
  104. package/dist/test/util/softlink.test.d.ts +2 -0
  105. package/dist/test/util/softlink.test.d.ts.map +1 -0
  106. package/dist/test/util/softlink.test.js +82 -0
  107. package/dist/test/validation/contract.test.d.ts +2 -0
  108. package/dist/test/validation/contract.test.d.ts.map +1 -0
  109. package/dist/test/validation/contract.test.js +116 -0
  110. package/dist/test/validation/credentials.test.d.ts +2 -0
  111. package/dist/test/validation/credentials.test.d.ts.map +1 -0
  112. package/dist/test/validation/credentials.test.js +74 -0
  113. package/dist/test/validation/projects-index.test.d.ts +2 -0
  114. package/dist/test/validation/projects-index.test.d.ts.map +1 -0
  115. package/dist/test/validation/projects-index.test.js +48 -0
  116. package/dist/test/validation/testcase-v1-draft.test.d.ts +2 -0
  117. package/dist/test/validation/testcase-v1-draft.test.d.ts.map +1 -0
  118. package/dist/test/validation/testcase-v1-draft.test.js +173 -0
  119. package/dist/vitest.config.d.ts +3 -0
  120. package/dist/vitest.config.d.ts.map +1 -0
  121. package/dist/vitest.config.js +7 -0
  122. package/docs/README.md +124 -124
  123. package/examples/schemeresource-observable-docs/schemeresource.context.md +20 -20
  124. package/examples/schemeresource.module-hints.json +38 -38
  125. package/examples/schemeresource.observable.code_list.md +37 -37
  126. package/examples/zwplace-observable-docs/zwplace.context.md +16 -16
  127. package/examples/zwplace-placecategory-validation.json +29 -29
  128. package/examples/zwplace.module-hints.json +69 -69
  129. package/examples/zwplace.observable.code_list.md +37 -37
  130. package/package.json +19 -13
  131. package/codex-skill/ep-stage/glue-generate-testcase/references/testcase-tpl.md +0 -72
  132. package/codex-skill/ep-stage/glue-run-test/scripts/extract-page-elements-eval.mjs +0 -233
  133. package/codex-skill/ep-stage/page-element-scan/SKILL.md +0 -91
  134. package/codex-skill/ep-stage/page-element-scan/evals/evals.json +0 -37
  135. package/codex-skill/ep-stage/page-element-scan/evals/fixtures/sample-list-page.html +0 -85
  136. package/codex-skill/ep-stage/page-element-scan/references/pagination-controls.md +0 -153
  137. package/codex-skill/ep-stage/page-element-scan/scripts/extract-page-elements-eval.mjs +0 -233
  138. package/codex-skill/ep-stage/page-element-scan/scripts/extract-page-elements.mjs +0 -298
  139. package/codex-skill/ep-stage/page-element-scan/scripts/scan-page.mjs +0 -749
  140. package/dist/src/cli/probe.d.ts +0 -46
  141. package/dist/src/cli/probe.d.ts.map +0 -1
  142. package/dist/src/cli/probe.js +0 -447
  143. package/dist/src/cli/run-report.d.ts +0 -46
  144. package/dist/src/cli/run-report.d.ts.map +0 -1
  145. package/dist/src/cli/run-report.js +0 -331
  146. package/dist/src/extractors/prd-extractor.d.ts +0 -70
  147. package/dist/src/extractors/prd-extractor.d.ts.map +0 -1
  148. package/dist/src/extractors/prd-extractor.js +0 -248
  149. package/dist/src/testcase/button-interaction-inference.d.ts +0 -37
  150. package/dist/src/testcase/button-interaction-inference.d.ts.map +0 -1
  151. package/dist/src/testcase/button-interaction-inference.js +0 -385
  152. package/dist/src/testcase/prd-button-mapper.d.ts +0 -72
  153. package/dist/src/testcase/prd-button-mapper.d.ts.map +0 -1
  154. package/dist/src/testcase/prd-button-mapper.js +0 -148
  155. package/dist/src/testcase/testcase-v2.d.ts +0 -50
  156. package/dist/src/testcase/testcase-v2.d.ts.map +0 -1
  157. package/dist/src/testcase/testcase-v2.js +0 -1
  158. package/docs/mvp-usage-guide.md +0 -298
@@ -1,749 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scan-page.mjs — 用 Playwright 实时渲染页面,提取页面中的可操作元素。
4
- *
5
- * 输出 JSON(可选 Markdown):每个元素带 type / label / locator / visible / enabled /
6
- * evidence(both | runtime-only | source-only)/ frame。
7
- *
8
- * 用法:
9
- * node scan-page.mjs --url <页面URL> --out elements.json [--md elements.md]
10
- * [--source 源html路径] [--storage-state 登录态json]
11
- * [--login-url URL --login-user 账号 --login-pass 密码]
12
- * [--project-dir 含playwright依赖的目录] [--wait 毫秒]
13
- * [--include-hidden] [--headed]
14
- *
15
- * 该脚本是 page-element-scan 技能的验证版实现;验证通过后逻辑将迁入
16
- * packages/ep-stage-skill 的 CLI 子命令。
17
- */
18
- import { createRequire } from 'node:module';
19
- import fs from 'node:fs';
20
- import path from 'node:path';
21
-
22
- // 共享 DOM 扫描内核(也供 playwright-cli eval 直接注入使用)
23
-
24
- // ---------------------------------------------------------------------------
25
- // 参数解析
26
- // ---------------------------------------------------------------------------
27
- function parseArgs(argv) {
28
- const args = { wait: 1500, includeHidden: false, headed: false, projectDir: process.cwd() };
29
- for (let i = 2; i < argv.length; i += 1) {
30
- const key = argv[i];
31
- const next = () => argv[++i];
32
- switch (key) {
33
- case '--url': args.url = next(); break;
34
- case '--out': args.out = next(); break;
35
- case '--md': args.md = next(); break;
36
- case '--source': args.source = next(); break;
37
- case '--storage-state': args.storageState = next(); break;
38
- case '--login-url': args.loginUrl = next(); break;
39
- case '--login-user': args.loginUser = next(); break;
40
- case '--login-pass': args.loginPass = next(); break;
41
- case '--login-user-selector': args.loginUserSelector = next(); break;
42
- case '--login-pass-selector': args.loginPassSelector = next(); break;
43
- case '--login-submit-selector': args.loginSubmitSelector = next(); break;
44
- case '--project-dir': args.projectDir = next(); break;
45
- case '--wait': args.wait = Number(next()); break;
46
- case '--include-hidden': args.includeHidden = true; break;
47
- case '--headed': args.headed = true; break;
48
- case '--contract-gaps': args.contractGaps = next(); break;
49
- case '--contract-ref': args.contractRef = next(); break;
50
- default:
51
- if (key.startsWith('--')) throw new Error(`未知参数: ${key}`);
52
- }
53
- }
54
- if (!args.url) throw new Error('缺少必填参数 --url');
55
- if (!args.out) throw new Error('缺少必填参数 --out');
56
- return args;
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // 页内提取器(在浏览器上下文执行,必须是自包含函数,不能引用外层变量)
61
- // ---------------------------------------------------------------------------
62
- function extractElementsInPage() {
63
- const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
64
-
65
- function isVisible(el) {
66
- const style = window.getComputedStyle(el);
67
- if (style.display === 'none' || style.visibility === 'hidden') return false;
68
- const rect = el.getBoundingClientRect();
69
- return rect.width > 0 && rect.height > 0;
70
- }
71
-
72
- function isEnabled(el) {
73
- const cls = el.className || '';
74
- return !el.disabled
75
- && el.getAttribute('aria-disabled') !== 'true'
76
- && !String(cls).includes('mini-disabled')
77
- && !String(cls).includes('disabled');
78
- }
79
-
80
- function fieldLabel(el) {
81
- // 分页控件(页码输入框、页面尺寸下拉框):不在表单字段采集范围内
82
- if (el.classList.contains('pagination-number')) return '';
83
- if (el.closest('.mini-pagination, .pagination-pagerchange')) return '';
84
- const wrapper = el.closest('[label]');
85
- if (wrapper) return norm(wrapper.getAttribute('label'));
86
- if (el.getAttribute('aria-label')) return norm(el.getAttribute('aria-label'));
87
- if (el.id) {
88
- const lab = document.querySelector(`label[for="${el.id}"]`);
89
- if (lab) return norm(lab.innerText);
90
- }
91
- // 同容器内的 label 元素(epoint 查询区常见结构)
92
- const containerLabel = el.closest('div, td')?.querySelector('label');
93
- if (containerLabel) return norm(containerLabel.innerText).replace(/[::]$/, '');
94
- const prev = el.closest('td, div')?.previousElementSibling;
95
- if (prev && /label|td/.test(prev.tagName.toLowerCase())) return norm(prev.innerText).replace(/[::]$/, '');
96
- return norm(el.placeholder || el.getAttribute('bind') || el.name || el.id || '').replace(/^[  ]+|[  ]+$/g, '') || '';
97
- }
98
-
99
- function controlType(el) {
100
- const cls = String(el.className || '');
101
- const mini = cls.match(/mini-[a-z]+/);
102
- if (mini) return mini[0];
103
- if (el.tagName === 'SELECT') return 'html-select';
104
- if (el.tagName === 'TEXTAREA') return 'html-textarea';
105
- return `html-${el.type || 'input'}`;
106
- }
107
-
108
- /**
109
- * 为隐藏的 radio/checkbox input(MiniUI 框架渲染产物)生成可用的定位符。
110
- * 框架结构:<div class="mini-radiobuttonlist-item" id="mini-8$0">
111
- * <input style="display:none" id="mini-8$ck$0" type="radio">
112
- * <label for="mini-8$ck$0">文本</label>
113
- * </div>
114
- * 直接用 #mini-8$ck$0 会因 $ 在 CSS 选择器中是特殊字符而失败。
115
- * 改用父 div 的 id 或 xpath=//div[contains(@class,'mini-radiobuttonlist-item')][normalize-space()='文本']
116
- */
117
- function hiddenInputLocator(el, label) {
118
- const tag = el.tagName.toLowerCase();
119
- // 尝试找 .mini-radiobuttonlist-item 或 .mini-checkbox 父元素
120
- const itemDiv = el.closest('.mini-radiobuttonlist-item, .mini-checkbox');
121
- if (itemDiv && itemDiv.id) {
122
- return `#${CSS.escape(itemDiv.id)}`;
123
- }
124
- if (label) {
125
- const type = el.type === 'radio' ? 'mini-radiobuttonlist-item' : 'mini-checkbox';
126
- return `xpath=//div[contains(@class,'${type}')][normalize-space()='${label}']`;
127
- }
128
- return null;
129
- }
130
-
131
- function fieldLocator(el, label) {
132
- const type = controlType(el);
133
- const tag = el.tagName.toLowerCase();
134
- if (label && type.startsWith('mini-')) {
135
- // 先尝试精确匹配 div[@label],找不到时用 contains 宽匹
136
- const exact = `xpath=//div[@label='${label}']//${tag}[contains(@class,'${type}') or contains(@class,'${type}-input')]`;
137
- if (document.querySelector(`div[label='${label}']`)) return exact;
138
- // 宽匹:label 含动态数字(如"从第页导出到第页")时用 contains
139
- const bareLabel = label.replace(/\d+/g, '').replace(/\s+/g, ' ').trim();
140
- return `xpath=//div[@label and contains(@label,'${bareLabel.replace(/[^一-龥a-zA-Z]/g, '')}')]//${tag}[contains(@class,'${type}') or contains(@class,'${type}-input')]`;
141
- }
142
- if (el.id) {
143
- // CSS 选择器中 $ 是特殊字符,直接用会解析失败
144
- if (el.id.includes('$')) {
145
- const prev = hiddenInputLocator(el, label);
146
- if (prev) return prev;
147
- }
148
- return `#${CSS.escape(el.id)}`;
149
- }
150
- if (el.name) return `${tag}[name="${el.name}"]`;
151
- if (label) return `xpath=//div[@label='${label}']//${tag}`;
152
- return tag;
153
- }
154
-
155
- function buttonLocator(el, label) {
156
- const tag = el.tagName.toLowerCase();
157
- const cls = String(el.className || '');
158
- if (cls.includes('mini-button')) {
159
- return `xpath=//${tag}[contains(@class,'mini-button')][normalize-space()='${label}']`;
160
- }
161
- if (cls.includes('cond-srh-btn')) {
162
- return `xpath=//a[contains(@class,'cond-srh-btn')][normalize-space()='${label}']`;
163
- }
164
- if (el.id) return `#${el.id}`;
165
- return `role=button[name="${label}"]`;
166
- }
167
-
168
- const elements = [];
169
- const seen = new Set();
170
- function push(el) {
171
- const key = `${el.type}|${el.label}|${el.locator}`;
172
- if (seen.has(key)) return;
173
- seen.add(key);
174
- elements.push(el);
175
- }
176
-
177
- // 按钮(含 MiniUI 按钮、epoint cond-srh-btn 查询按钮、带 tooltip 的图标按钮)
178
- document.querySelectorAll(
179
- 'a.mini-button, button, input[type=button], input[type=submit], input[type=reset], [role=button], a.cond-srh-btn, a.mini-tools, span.mini-tools',
180
- ).forEach((el) => {
181
- const label = norm(el.innerText || el.value || el.getAttribute('data-tooltip') || el.title || '');
182
- if (!label) return;
183
- push({
184
- type: 'button',
185
- label,
186
- locator: buttonLocator(el, label),
187
- visible: isVisible(el),
188
- enabled: isEnabled(el),
189
- });
190
- });
191
-
192
- // 表单字段
193
- document.querySelectorAll(
194
- 'input:not([type=button]):not([type=submit]):not([type=reset]):not([type=hidden]), textarea, select',
195
- ).forEach((el) => {
196
- // MiniUI 渲染后真实 input 常藏在控件内部;隐藏的占位 input 跳过(由可视输入代表)
197
- if (!isVisible(el) && el.closest('.mini-textbox, .mini-combobox, .mini-datepicker, .mini-spinner')) return;
198
- // MiniUI RadioButtonList/CheckBox 的隐藏 radio/checkbox input 跳过,由父 div 代表
199
- if ((el.type === 'radio' || el.type === 'checkbox') && !isVisible(el)) return;
200
- const label = fieldLabel(el);
201
- if (!label) return; // 过滤分页跳页等无业务意义的噪音输入
202
- push({
203
- type: 'field',
204
- label,
205
- controlType: controlType(el),
206
- locator: fieldLocator(el, label),
207
- visible: isVisible(el),
208
- enabled: isEnabled(el),
209
- required: el.required || el.getAttribute('required') === 'true' || !!el.closest('.starred, [starred]'),
210
- });
211
- });
212
-
213
- // MiniUI RadioButtonList / CheckBox 独立控件(父 div 可点击)
214
- document.querySelectorAll('.mini-radiobuttonlist-item, .mini-checkbox').forEach((el) => {
215
- const input = el.querySelector('input[type=radio], input[type=checkbox]');
216
- if (!input) return;
217
- // 取纯净文本:label 内嵌的 mini-export-num input 的 value 不应混入文本
218
- let label = norm(input.labels?.[0]?.innerText || el.innerText || '').replace(/[::]$/, '');
219
- if (!label) return;
220
- // 去掉内嵌 input 的 value 数字(如 "从第1页导出到第2页" → "从第页导出到第页")
221
- const numInput = el.querySelector('.mini-export-num');
222
- if (numInput) {
223
- label = label.replace(/\d+/g, '');
224
- }
225
- const isRadio = input.type === 'radio';
226
- const type = isRadio ? 'field' : 'field';
227
- const controlType2 = isRadio ? 'mini-radiobuttonlist' : 'mini-checkbox';
228
- // 用父 div id(已含 $,需 CSS.escape)或文本定位
229
- const itemId = el.id;
230
- const locator = itemId ? `#${CSS.escape(itemId)}`
231
- : `xpath=//div[contains(@class,'${isRadio ? 'mini-radiobuttonlist-item' : 'mini-checkbox'})][contains(normalize-space(),'${label}')]`;
232
- push({
233
- type,
234
- label,
235
- controlType: controlType2,
236
- locator,
237
- visible: isVisible(el),
238
- enabled: isEnabled(el),
239
- });
240
- });
241
-
242
- // 列表(MiniUI datagrid):表头、行操作图标
243
- document.querySelectorAll('.mini-datagrid').forEach((grid) => {
244
- const gridId = grid.id || 'datagrid';
245
- const headers = [...grid.querySelectorAll('.mini-grid-headertxt, .mini-grid-headerText, th')]
246
- .map((h) => norm(h.innerText)).filter(Boolean);
247
- const rowActions = [...grid.querySelectorAll('[data-tooltip]')]
248
- .map((a) => norm(a.getAttribute('data-tooltip'))).filter(Boolean);
249
- push({
250
- type: 'grid',
251
- label: gridId,
252
- locator: `#${gridId}`,
253
- visible: isVisible(grid),
254
- enabled: true,
255
- columns: headers,
256
- rowActions: [...new Set(rowActions)],
257
- });
258
- });
259
-
260
- // 标签页
261
- document.querySelectorAll('.mini-tabs li, [role=tab]').forEach((el) => {
262
- const label = norm(el.innerText);
263
- if (!label) return;
264
- push({
265
- type: 'tab',
266
- label,
267
- locator: `role=tab[name="${label}"]`,
268
- visible: isVisible(el),
269
- enabled: isEnabled(el),
270
- });
271
- });
272
-
273
- // 普通链接(排除已按按钮收录的)
274
- document.querySelectorAll('a[href]:not(.mini-button)').forEach((el) => {
275
- const label = norm(el.innerText);
276
- if (!label || !isVisible(el)) return;
277
- push({
278
- type: 'link',
279
- label,
280
- locator: `role=link[name="${label}"]`,
281
- visible: true,
282
- enabled: isEnabled(el),
283
- href: el.getAttribute('href'),
284
- });
285
- });
286
-
287
- // MiniUI 分页控件
288
- document.querySelectorAll('.mini-grid-pager').forEach((pager) => {
289
- const parentGrid = pager.closest('.mini-datagrid');
290
- const gridId = parentGrid?.id || 'datagrid';
291
- const prefix = parentGrid ? `#${CSS.escape(gridId)}` : '';
292
-
293
- // 总条数文本
294
- const pagerInfo = pager.querySelector('.pagination-pagerinfo');
295
- if (pagerInfo) {
296
- push({ type: 'pagination', label: norm(pagerInfo.innerText), locator: `${prefix} .pagination-pagerinfo`, visible: true, enabled: true });
297
- }
298
-
299
- // 分页按钮
300
- pager.querySelectorAll('.pagination-buttons a').forEach((btn) => {
301
- const action = btn.getAttribute('action');
302
- const isEllipsis = btn.classList.contains('pagination-button-ellipsis');
303
- let label = norm(btn.innerText) || '';
304
- let subType = '';
305
- let suffix = '';
306
- if (action === 'prev') { subType = 'pagination-prev'; label = '上一页'; suffix = '.pagination-buttons a[action=prev]'; }
307
- else if (action === 'next') { subType = 'pagination-next'; label = '下一页'; suffix = '.pagination-buttons a[action=next]'; }
308
- else if (isEllipsis || action === 'none') { subType = 'pagination-ellipsis'; label = '...'; suffix = '.pagination-button-ellipsis'; }
309
- else { subType = 'pagination-page'; label = norm(btn.innerText); suffix = '.pagination-buttons a.pagination-button:not(.pagination-button-ellipsis)'; }
310
- push({ type: 'pagination', subType, label, locator: `${prefix} ${suffix}`, visible: isVisible(btn), enabled: !btn.classList.contains('disabled') });
311
- });
312
-
313
- // 页面尺寸下拉
314
- const pageSizeBtn = pager.querySelector('.mini-pagination > span.mini-buttonedit');
315
- if (pageSizeBtn && isVisible(pageSizeBtn)) {
316
- const hiddenVal = pageSizeBtn.querySelector('input[type=hidden]');
317
- push({ type: 'pagination', subType: 'pagination-size', label: '页面尺寸', locator: `${prefix} .mini-pagination > span.mini-buttonedit`, value: hiddenVal?.value || '', visible: true, enabled: true });
318
- }
319
-
320
- // 页码跳转输入框
321
- const jumpInput = pager.querySelector('.pagination-pagerchange input.pagination-number');
322
- if (jumpInput && isVisible(jumpInput)) {
323
- push({ type: 'pagination', subType: 'pagination-jump', label: '跳至页', locator: `${prefix} .pagination-pagerchange input.pagination-number`, visible: true, enabled: isEnabled(jumpInput) });
324
- }
325
- });
326
-
327
- return { title: document.title, elements };
328
- }
329
-
330
- // 源代码侧额外提取:script 中的弹窗触发器(epoint.openTopDialog)
331
- function extractDialogsFromSource() {
332
- const dialogs = [];
333
- const scripts = [...document.querySelectorAll('script')].map((s) => s.textContent || '').join('\n');
334
- const pattern = /function\s+([A-Za-z0-9_]+)\s*\([^)]*\)\s*\{[\s\S]*?openTopDialog\('([^']+)'\s*,\s*'([^']+)'/g;
335
- for (const m of scripts.matchAll(pattern)) {
336
- dialogs.push({ trigger: m[1], title: m[2], pagePath: m[3] });
337
- }
338
- return dialogs;
339
- }
340
-
341
- // ---------------------------------------------------------------------------
342
- // 合并:按 type+label 匹配,产出证据标记
343
- // ---------------------------------------------------------------------------
344
- function mergeEvidence(runtimeElements, sourceElements) {
345
- const key = (e) => `${e.type}|${e.label}`;
346
- const sourceMap = new Map(sourceElements.map((e) => [key(e), e]));
347
- const runtimeKeys = new Set(runtimeElements.map(key));
348
-
349
- const merged = runtimeElements.map((e) => ({
350
- ...e,
351
- evidence: sourceMap.has(key(e)) ? 'both' : 'runtime-only',
352
- }));
353
- for (const e of sourceElements) {
354
- if (!runtimeKeys.has(key(e))) {
355
- merged.push({ ...e, visible: false, enabled: false, evidence: 'source-only' });
356
- }
357
- }
358
- return merged;
359
- }
360
-
361
- function summarize(elements) {
362
- const byType = {};
363
- for (const e of elements) byType[e.type] = (byType[e.type] || 0) + 1;
364
- return {
365
- total: elements.length,
366
- byType,
367
- actionable: elements.filter((e) => e.visible && e.enabled).length,
368
- hiddenAtRuntime: elements.filter((e) => e.evidence === 'both' && !e.visible).length,
369
- runtimeOnly: elements.filter((e) => e.evidence === 'runtime-only').length,
370
- sourceOnly: elements.filter((e) => e.evidence === 'source-only').length,
371
- };
372
- }
373
-
374
- function toMarkdown(result) {
375
- const lines = [
376
- `# 页面可操作元素清单:${result.title || result.url}`,
377
- '',
378
- `- URL: ${result.url}`,
379
- `- 元素总数: ${result.summary.total}(可操作 ${result.summary.actionable})`,
380
- `- 分类: ${Object.entries(result.summary.byType).map(([t, n]) => `${t}=${n}`).join(', ')}`,
381
- `- 证据: both=${result.summary.total - result.summary.runtimeOnly - result.summary.sourceOnly}, runtime-only=${result.summary.runtimeOnly}, source-only=${result.summary.sourceOnly}`,
382
- '',
383
- '| 类型 | 标签 | 可见 | 可用 | 证据 | 定位符 |',
384
- '| --- | --- | --- | --- | --- | --- |',
385
- ];
386
- for (const e of result.elements) {
387
- lines.push(`| ${e.type} | ${e.label} | ${e.visible ? '✓' : '✗'} | ${e.enabled ? '✓' : '✗'} | ${e.evidence || 'runtime'} | \`${e.locator}\` |`);
388
- }
389
- lines.push('');
390
- return lines.join('\n');
391
- }
392
-
393
- // ---------------------------------------------------------------------------
394
- // 读取 contract.json5,提取所有已有槽位路径(用于去重)
395
- // ---------------------------------------------------------------------------
396
-
397
- /**
398
- * 读取 contract JSON5,返回所有已知槽位路径的 Set。
399
- * 覆盖:frames、locators、searchConditions、childGrids[].gridId。
400
- */
401
- function loadContractSlots(contractPath) {
402
- // 直接用正则从 contract.json5 文本提取关键槽位路径,不依赖 JSON5 解析
403
- const raw = fs.readFileSync(contractPath, 'utf8');
404
- const known = new Set();
405
- // 额外:收集所有已存在的 locator 值(用于去重 locator-candidate)
406
- const knownLocators = new Set();
407
-
408
- // frames.*
409
- for (const m of raw.matchAll(/frames:\s*\{([^}]+)\}/g)) {
410
- for (const key of m[1].matchAll(/(\w+):\s*"([^"]+)"/g)) known.add(`frames.${key[1]}`);
411
- }
412
-
413
- // locators.*(同时收集 locator 值)
414
- // 用行级解析:逐行找 key: "value" 模式,不受跨行结构影响
415
- const inLocators = { v: false };
416
- for (const line of raw.split('\n')) {
417
- if (/^\s*locators:\s*\{/.test(line)) { inLocators.v = true; continue; }
418
- if (inLocators.v && /^\s*\}/.test(line)) { inLocators.v = false; break; }
419
- if (inLocators.v) {
420
- const m = line.match(/^\s+(\w+):\s*"([^"]+)"/);
421
- if (m) { known.add(`locators.${m[1]}`); knownLocators.add(m[2]); }
422
- }
423
- }
424
-
425
- // searchConditions[].label / field
426
- for (const m of raw.matchAll(/label:\s*'([^']+)'/g)) known.add(`searchConditions.${m[1].replace(/\s+/g, '')}`);
427
- for (const m of raw.matchAll(/field:\s*'([^']+)'/g)) known.add(`searchConditions.${m[1]}`);
428
-
429
- // childGrids[].gridId
430
- for (const m of raw.matchAll(/gridId:\s*'([^']+)'/g)) known.add(`childGrids.${m[1]}.gridId`);
431
-
432
- // childGrids[].columns[].field(按行扫描,避免跨行结构)
433
- let inChildGrid = false, gridId = '';
434
- for (const line of raw.split('\n')) {
435
- const gm = line.match(/gridId:\s*'([^']+)'/);
436
- if (gm) { inChildGrid = true; gridId = gm[1]; }
437
- if (inChildGrid) {
438
- const cm = line.match(/field:\s*'([^']+)'/);
439
- if (cm) known.add(`childGrids.${gridId}.columns.${cm[1]}`);
440
- }
441
- if (line.trim() === ']' && inChildGrid) inChildGrid = false;
442
- }
443
-
444
- // gapCandidates[].gapId
445
- for (const m of raw.matchAll(/gapId:\s*'([^']+)'/g)) known.add(`gapCandidates.${m[1]}`);
446
-
447
- return { known, knownLocators };
448
- }
449
-
450
- // ---------------------------------------------------------------------------
451
- // 方案 B:将运行时发现的控件转为 contract 槽位补全建议(与 contract diff)
452
- // ---------------------------------------------------------------------------
453
-
454
- /**
455
- * 从扫描结果中提炼 contract 缺失槽位,输出 candidateSlots 格式。
456
- *
457
- * 推断规则:
458
- * - frames.iframeSrcKeyword:非 main frame 的 url path 稳定部分
459
- * - locators.*:工具栏按钮(button.visible && button.enabled)且 label 唯一
460
- * - childGrids[].gridId:type=grid 的 label(非 main frame)
461
- * - childGrids[].columns:grid 的 columns 数组
462
- */
463
- function toContractGaps(result, { known = new Set(), knownLocators = new Set() } = {}) {
464
- // 只取 main frame 的元素;子 frame 单独处理(避免登录帧残留污染主清单)
465
- const nonMain = result.elements.filter(e => e.frame !== 'main');
466
- const main = result.elements.filter(e => e.frame === 'main');
467
- const gaps = [];
468
-
469
- // 1. 发现新 iframe(frames 下尚无的 src 关键字)
470
- const seenFrames = new Set(nonMain.map(e => e.frame).filter(Boolean));
471
- for (const frame of seenFrames) {
472
- const slotKey = `frames.${frame.replace('.html', '')}IframeSrcKeyword`;
473
- if (known.has(slotKey)) continue;
474
- gaps.push({
475
- gapType: 'runtime-only-frame',
476
- slotKey,
477
- slotValue: frame,
478
- evidence: '运行时发现子 frame 文件名,contract 中不存在',
479
- priority: 'high',
480
- });
481
- }
482
-
483
- // 2. 发现新按钮(locators 中尚无的可见可用按钮)
484
- const visibleBtns = main.filter(e => e.type === 'button' && e.visible && e.enabled);
485
- for (const btn of visibleBtns) {
486
- const skipLabels = ['搜索', '查询', '重置', '清空', '登录', '登录信息', '忘记密码'];
487
- if (skipLabels.some(s => btn.label.includes(s))) continue;
488
- // 如果 locator 值已存在于 contract 任何槽位,不算新 gap
489
- if (knownLocators.has(btn.locator)) continue;
490
- const slotKey = `locators.${btn.label.replace(/\s+/g, '')}`;
491
- if (known.has(slotKey)) continue;
492
- gaps.push({
493
- gapType: 'locator-candidate',
494
- slotKey,
495
- slotValue: btn.locator,
496
- evidence: '运行时渲染后可见可用,定位符已验证',
497
- priority: 'medium',
498
- });
499
- }
500
-
501
- // 3. 发现子表(type=grid 且非 main frame)
502
- const grids = nonMain.filter(e => e.type === 'grid' && e.visible);
503
- for (const g of grids) {
504
- const gridIdKey = `childGrids.${g.label}.gridId`;
505
- if (!known.has(gridIdKey)) {
506
- gaps.push({
507
- gapType: 'child-grid',
508
- slotKey: gridIdKey,
509
- slotValue: g.label,
510
- evidence: `子表 grid id=${g.label},列:${(g.columns || []).join(', ')}`,
511
- priority: 'high',
512
- });
513
- }
514
- if (g.columns?.length) {
515
- const colsKey = `childGrids.${g.label}.columns`;
516
- if (!known.has(colsKey)) {
517
- gaps.push({
518
- gapType: 'child-grid-columns',
519
- slotKey: colsKey,
520
- slotValue: g.columns,
521
- evidence: '运行时渲染 DOM 提取,精确到字段级',
522
- priority: 'medium',
523
- });
524
- }
525
- }
526
- }
527
-
528
- // 4. 发现新的可见查询字段(main frame 的 field,不含登录残留)
529
- const visibleFields = main.filter(e => e.type === 'field' && e.visible && e.enabled);
530
- for (const f of visibleFields) {
531
- const skipPatterns = [/^仅导出/, /^导出/, /^从第页/, /^请/, /密码/, /用户名/, /账号/];
532
- if (skipPatterns.some(p => p.test(f.label))) continue;
533
- const slotKey = `searchConditions.${f.label.replace(/\s+/g, '')}`;
534
- if (known.has(slotKey)) continue;
535
- gaps.push({
536
- gapType: 'field-candidate',
537
- slotKey,
538
- slotValue: { label: f.label, component: f.controlType || 'input', locator: f.locator },
539
- evidence: '运行时渲染后可见,定位符已验证',
540
- priority: 'low',
541
- });
542
- }
543
-
544
- return {
545
- url: result.url,
546
- scannedAt: result.scannedAt,
547
- totalGaps: gaps.length,
548
- gaps,
549
- };
550
- }
551
-
552
- // ---------------------------------------------------------------------------
553
- // 主流程
554
- // ---------------------------------------------------------------------------
555
- async function main() {
556
- const args = parseArgs(process.argv);
557
- const require = createRequire(path.join(args.projectDir, 'noop.js'));
558
- let chromium;
559
- for (const pkg of ['playwright', '@playwright/test', 'playwright-core']) {
560
- try { ({ chromium } = require(pkg)); break; } catch { /* 尝试下一个 */ }
561
- }
562
- if (!chromium) throw new Error(`在 ${args.projectDir} 下找不到 playwright,请检查 --project-dir`);
563
-
564
- const browser = await chromium.launch({ headless: !args.headed });
565
- const context = await browser.newContext(
566
- args.storageState ? { storageState: args.storageState } : {},
567
- );
568
- const page = await context.newPage();
569
-
570
- try {
571
- // 登录(先点提交,等 URL 离开登录页即表示登录完成)
572
- if (args.loginUser) {
573
- await page.goto(args.loginUrl || args.url, { waitUntil: 'domcontentloaded' });
574
- const userSel = args.loginUserSelector || 'input[type=text]:visible, input:not([type]):visible';
575
- const passSel = args.loginPassSelector || 'input[type=password]:visible';
576
- const submitSel = args.loginSubmitSelector
577
- || 'input[type=submit], button[type=submit], a:has-text("登录"), button:has-text("登录")';
578
- await page.locator(userSel).first().fill(args.loginUser);
579
- await page.locator(passSel).first().fill(args.loginPass || '');
580
- await page.locator(submitSel).first().click();
581
- // 等待 URL 离开登录页(登录完成后的第一个目标系统页面)
582
- await page.waitForFunction(
583
- () => !window.location.href.includes('/login/') && !window.location.href.includes('/login.html'),
584
- { timeout: 30000 },
585
- );
586
- await page.waitForTimeout(1500); // 等目标页面框架渲染完成
587
- }
588
-
589
- await page.goto(args.url, { waitUntil: 'domcontentloaded' });
590
- await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
591
- await page.waitForTimeout(args.wait); // 等 MiniUI 等框架渲染
592
-
593
- // 运行时提取:主 frame + 所有子 frame
594
- const runtime = [];
595
- let title = '';
596
- for (const frame of page.frames()) {
597
- try {
598
- const r = await frame.evaluate(extractElementsInPage);
599
- if (frame === page.mainFrame()) title = r.title;
600
- const frameTag = frame === page.mainFrame()
601
- ? 'main'
602
- : new URL(frame.url()).pathname.split('/').pop() || 'sub';
603
- for (const e of r.elements) runtime.push({ ...e, frame: frameTag });
604
- } catch { /* 跨域 frame 跳过 */ }
605
- }
606
-
607
- // 源代码提取(可选):用 DOMParser 解析原始 HTML(不执行脚本),
608
- // 得到纯标记态元素;运行时渲染后得到运行时态元素。
609
- // 两者按 type+label 比对,标注 evidence:both / runtime-only / source-only。
610
- let sourceElements = [];
611
- let dialogs = [];
612
- if (args.source) {
613
- const html = fs.readFileSync(args.source, 'utf8');
614
-
615
- // 用 DOMParser 解析(不执行脚本,得到纯声明式 DOM)
616
- const sr = await page.evaluate((srcHtml) => {
617
- const parser = new DOMParser();
618
- const doc = parser.parseFromString(srcHtml, 'text/html');
619
- const scripts = [...doc.querySelectorAll('script')].map((s) => s.textContent || '').join('\n');
620
- // 复用提取逻辑中的选择器+可见性判断(直接用 doc 查询,不用 page context)
621
- function queryAll(sel) { return [...doc.querySelectorAll(sel)]; }
622
- const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
623
- function fieldLabel(el) {
624
- // 分页控件(页码输入框、页面尺寸下拉框):不在表单字段采集范围内
625
- if (el.classList.contains('pagination-number')) return '';
626
- if (el.closest('.mini-pagination, .pagination-pagerchange')) return '';
627
- const wrapper = el.closest('[label]');
628
- if (wrapper) return norm(wrapper.getAttribute('label'));
629
- if (el.getAttribute('aria-label')) return norm(el.getAttribute('aria-label'));
630
- const containerLabel = el.closest('div, td')?.querySelector('label');
631
- if (containerLabel) return norm(containerLabel.innerText).replace(/[::]$/, '');
632
- return norm(el.placeholder || el.getAttribute('bind') || el.name || el.id || '').replace(/^[  ]+|[  ]+$/g, '') || '';
633
- }
634
- function controlType(el) {
635
- const cls = String(el.className || '');
636
- const mini = cls.match(/mini-[a-z]+/);
637
- if (mini) return mini[0];
638
- if (el.tagName === 'SELECT') return 'html-select';
639
- if (el.tagName === 'TEXTAREA') return 'html-textarea';
640
- return `html-${el.type || 'input'}`;
641
- }
642
- function fieldLocator(el, label) {
643
- const type = controlType(el);
644
- const tag = el.tagName.toLowerCase();
645
- if (label && type.startsWith('mini-')) {
646
- return `xpath=//div[@label='${label}']//${tag}[contains(@class,'${type}') or contains(@class,'${type}-input')]`;
647
- }
648
- if (el.id) {
649
- if (el.id.includes('$')) return null; // 源码侧这类 id 不会存在,跳过
650
- return `#${el.id}`;
651
- }
652
- if (el.name) return `${tag}[name="${el.name}"]`;
653
- if (label) return `xpath=//div[@label='${label}']//${tag}`;
654
- return tag;
655
- }
656
- function buttonLocator(el, label) {
657
- const tag = el.tagName.toLowerCase();
658
- const cls = String(el.className || '');
659
- if (cls.includes('mini-button')) return `xpath=//${tag}[contains(@class,'mini-button')][normalize-space()='${label}']`;
660
- if (cls.includes('cond-srh-btn')) return `xpath=//a[contains(@class,'cond-srh-btn')][normalize-space()='${label}']`;
661
- if (el.id) return `#${el.id}`;
662
- return `role=button[name="${label}"]`;
663
- }
664
- const seen = new Set();
665
- function push(els, el) {
666
- const key = `${el.type}|${el.label}|${el.locator}`;
667
- if (seen.has(key)) return;
668
- seen.add(key); els.push(el);
669
- }
670
- const elements = [];
671
- // 按钮
672
- doc.querySelectorAll('a.mini-button, button, input[type=button], input[type=submit], input[type=reset], [role=button], a.cond-srh-btn, a.mini-tools, span.mini-tools')
673
- .forEach((el) => {
674
- const label = norm(el.innerText || el.value || el.getAttribute('data-tooltip') || el.title || '');
675
- if (!label) return;
676
- push(elements, { type: 'button', label, locator: buttonLocator(el, label), visible: true, enabled: true });
677
- });
678
- // 字段
679
- doc.querySelectorAll('input:not([type=button]):not([type=submit]):not([type=reset]):not([type=hidden]), textarea, select')
680
- .forEach((el) => {
681
- // MiniUI RadioButtonList/CheckBox 的隐藏 radio/checkbox input 跳过,由父 div 代表
682
- if ((el.type === 'radio' || el.type === 'checkbox')) return;
683
- const label = fieldLabel(el);
684
- push(elements, { type: 'field', label, controlType: controlType(el), locator: fieldLocator(el, label), visible: true, enabled: true, required: false });
685
- });
686
- // grid
687
- doc.querySelectorAll('.mini-datagrid').forEach((grid) => {
688
- const gridId = grid.id || 'datagrid';
689
- const headers = [...grid.querySelectorAll('.mini-grid-headertxt, .mini-grid-headerText, th')].map((h) => norm(h.innerText)).filter(Boolean);
690
- const rowActions = [...grid.querySelectorAll('[data-tooltip]')].map((a) => norm(a.getAttribute('data-tooltip'))).filter(Boolean);
691
- elements.push({ type: 'grid', label: gridId, locator: `#${gridId}`, visible: true, enabled: true, columns: headers, rowActions: [...new Set(rowActions)] });
692
- });
693
- // tabs
694
- doc.querySelectorAll('.mini-tabs li, [role=tab]').forEach((el) => {
695
- const label = norm(el.innerText);
696
- if (!label) return;
697
- push(elements, { type: 'tab', label, locator: `role=tab[name="${label}"]`, visible: true, enabled: true });
698
- });
699
- // 弹窗
700
- const pattern = /function\s+([A-Za-z0-9_]+)\s*\([^)]*\)\s*\{[\s\S]*?openTopDialog\('([^']+)'\s*,\s*'([^']+)'/g;
701
- const dialogs2 = [];
702
- for (const m of scripts.matchAll(pattern)) dialogs2.push({ trigger: m[1], title: m[2], pagePath: m[3] });
703
- return { elements, dialogs: dialogs2 };
704
- }, html);
705
- sourceElements = sr.elements;
706
- dialogs = sr.dialogs;
707
- }
708
-
709
- const elements = args.source
710
- ? mergeEvidence(runtime, sourceElements)
711
- : runtime.map((e) => ({ ...e, evidence: 'runtime' }));
712
- const filtered = args.includeHidden
713
- ? elements
714
- : elements.filter((e) => e.visible || e.evidence === 'source-only' || e.evidence === 'both');
715
-
716
- const result = {
717
- url: args.url,
718
- title,
719
- scannedAt: new Date().toISOString(),
720
- summary: summarize(filtered),
721
- dialogs,
722
- elements: filtered,
723
- };
724
-
725
- fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
726
- fs.writeFileSync(args.out, JSON.stringify(result, null, 2), 'utf8');
727
- if (args.md) fs.writeFileSync(args.md, toMarkdown(result), 'utf8');
728
-
729
- // 方案 B:输出 contract 槽位补全建议
730
- if (args.contractGaps) {
731
- const knownSlots = args.contractRef ? loadContractSlots(args.contractRef) : { known: new Set(), knownLocators: new Set() };
732
- const gapsResult = toContractGaps(result, knownSlots);
733
- fs.writeFileSync(args.contractGaps, JSON.stringify(gapsResult, null, 2), 'utf8');
734
- const kept = args.contractRef ? `(已比对 ${knownSlots.known.size} 个已知槽位)` : '(无 contract-ref,仅做启发式过滤)';
735
- console.log(`方案B输出: ${args.contractGaps}(${gapsResult.totalGaps} 条待补槽位)${kept}`);
736
- }
737
-
738
- console.log(`扫描完成: ${result.summary.total} 个元素(可操作 ${result.summary.actionable})`);
739
- console.log(`分类: ${JSON.stringify(result.summary.byType)}`);
740
- console.log(`输出: ${args.out}${args.md ? `, ${args.md}` : ''}`);
741
- } finally {
742
- await browser.close();
743
- }
744
- }
745
-
746
- main().catch((err) => {
747
- console.error(`扫描失败: ${err.message}`);
748
- process.exit(1);
749
- });