@epoint-testtech/ep-stage-skill 0.0.15 → 0.0.17

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 (43) hide show
  1. package/codex-skill/ep-stage/code-list-generate/SKILL.md +1 -1
  2. package/codex-skill/ep-stage/glue-create-project/SKILL.md +1 -1
  3. package/codex-skill/ep-stage/glue-generate-testcase/SKILL.md +7 -6
  4. package/codex-skill/ep-stage/glue-run-test/SKILL.md +100 -38
  5. package/codex-skill/ep-stage/glue-run-test/references/ai-runtime-loop.md +15 -9
  6. package/codex-skill/ep-stage/glue-run-test/references/crud-pipeline.md +10 -3
  7. package/codex-skill/ep-stage/glue-run-test/references/gap-review-protocol.md +11 -0
  8. package/codex-skill/ep-stage/glue-run-test/references/playwright-cli-odav.md +13 -2
  9. package/codex-skill/ep-stage/page-element-scan/SKILL.md +92 -91
  10. package/codex-skill/ep-stage/scripts/validate-skill.mjs +4 -1
  11. package/dist/src/cli/calibrate.d.ts +75 -0
  12. package/dist/src/cli/calibrate.d.ts.map +1 -0
  13. package/dist/src/cli/calibrate.js +318 -0
  14. package/dist/src/cli/demote.d.ts +53 -0
  15. package/dist/src/cli/demote.d.ts.map +1 -0
  16. package/dist/src/cli/demote.js +111 -0
  17. package/dist/src/cli/index.d.ts +5 -3
  18. package/dist/src/cli/index.d.ts.map +1 -1
  19. package/dist/src/cli/index.js +45 -6
  20. package/dist/src/cli/run-report.d.ts +6 -0
  21. package/dist/src/cli/run-report.d.ts.map +1 -1
  22. package/dist/src/cli/run-report.js +161 -147
  23. package/dist/src/cli/run.d.ts +51 -0
  24. package/dist/src/cli/run.d.ts.map +1 -1
  25. package/dist/src/cli/run.js +233 -31
  26. package/dist/src/contracts/glue-contract-v1.d.ts +37 -1
  27. package/dist/src/contracts/glue-contract-v1.d.ts.map +1 -1
  28. package/dist/src/extractors/code-list-json5.d.ts +29 -0
  29. package/dist/src/extractors/code-list-json5.d.ts.map +1 -1
  30. package/dist/src/generators/stage-skeleton-script.d.ts +30 -1
  31. package/dist/src/generators/stage-skeleton-script.d.ts.map +1 -1
  32. package/dist/src/generators/stage-skeleton-script.js +74 -11
  33. package/dist/src/runtime/ai-runtime-trace.d.ts +6 -0
  34. package/dist/src/runtime/ai-runtime-trace.d.ts.map +1 -1
  35. package/dist/src/testcase/contract-testcase-assembler.d.ts +23 -4
  36. package/dist/src/testcase/contract-testcase-assembler.d.ts.map +1 -1
  37. package/dist/src/testcase/contract-testcase-assembler.js +204 -27
  38. package/dist/src/testcase/query-component-map.d.ts +51 -0
  39. package/dist/src/testcase/query-component-map.d.ts.map +1 -0
  40. package/dist/src/testcase/query-component-map.js +64 -0
  41. package/dist/src/validation/contract.d.ts.map +1 -1
  42. package/dist/src/validation/contract.js +45 -3
  43. package/package.json +1 -1
@@ -1,91 +1,92 @@
1
- ---
2
- name: page-element-scan
3
- description: 基于 Playwright 实时渲染识别页面中的可操作元素(按钮、表单字段、列表行操作、标签页、链接等),输出带定位符和可见/可用状态的元素清单(JSON + Markdown)。可与页面源 html 交叉比对,标注「静态有渲染无」「渲染有静态无」的差异证据。当用户想盘点页面元素、摸清页面有哪些按钮和字段、生成页面元素清单、为自动化测试/用例编写摸清页面结构时使用,即使用户只给了一个 URL 也应主动使用本技能。
4
- ---
5
-
6
- # page-element-scan — 页面可操作元素识别
7
-
8
- ## 用途
9
-
10
- 对指定页面做一次「可操作元素盘点」:用 Playwright 真实渲染页面,提取所有用户可操作的元素,并为每个元素给出 Playwright 可用的定位符。
11
-
12
- 与静态读 html 源码的区别:运行时渲染才能回答「这个元素用户到底看不看得到、点不点得动」。 MiniUI 等框架会把声明式标记渲染成复杂 DOM,权限控制会让部分元素渲染时隐藏,脚本会动态创建新元素——这些只有渲染结果能告诉我们。如果用户同时提供了页面源 html,本技能会做双证据比对:
13
-
14
- - `both`:源码声明且渲染确认 —— 最可信
15
- - `runtime-only`:渲染时才出现(脚本动态创建)—— 静态分析会漏掉
16
- - `source-only`:源码声明但渲染时不可见(权限隐藏/逻辑移除)—— 对最终用户不可操作
17
-
18
- ## 输入收集
19
-
20
- 开始扫描前,向用户确认以下信息(能从上下文推断就不要再问):
21
-
22
- 1. **目标 URL**(必填)。本地 html 文件用 `file:///` 绝对路径。
23
- 2. **登录方式**(如需要):账号密码,或已有的 Playwright storage-state 文件。若当前目录是胶水/e2e 项目且有 `.env`(含 `LOGIN_SYSTEM_URL` / `LOGIN_USERNAME` / `LOGIN_PASSWORD`),直接读取复用,不要问用户要密码。
24
- 3. **源 html 路径**(可选):有就给 `--source`,触发双证据比对。
25
- 4. **输出位置**:默认 `./page-elements/` 下,`<页面名>.json` + `<页面名>.md`。
26
-
27
- ## 执行步骤
28
-
29
- 1. 确定一个安装了 playwright 的工作目录作为 `--project-dir`:当前项目根、或 `e2e-test/` 下任一胶水项目(它们的 node_modules 里有 playwright)。脚本用 `createRequire` 从该目录解析依赖。
30
-
31
- 2. 运行扫描脚本(路径相对于本技能目录):
32
-
33
- ```bash
34
- node <技能目录>/scripts/scan-page.mjs \
35
- --url "<目标URL>" \
36
- --out "<输出>.json" --md "<输出>.md" \
37
- --project-dir "<含playwright的目录>"
38
- ```
39
-
40
- 按需追加:
41
- - 登录:`--login-url <登录页> --login-user <账号> --login-pass <密码>`(启发式填用户名密码框);启发式失败时用 `--login-user-selector` / `--login-pass-selector` / `--login-submit-selector` 显式指定。
42
- - 登录态复用:`--storage-state <state.json>`。
43
- - 双证据:`--source <页面源html>`。
44
- - 渲染不充分时加大 `--wait 3000`;调试时 `--headed` 看浏览器实际行为。
45
-
46
- 3. 脚本默认扫描主 frame 和所有子 frame(epoint 页面常嵌 iframe),每个元素的 `frame` 字段标注来源;跨域 frame 会自动跳过。
47
-
48
- ## 结果呈现
49
-
50
- 扫描完成后读 JSON,向用户汇报:
51
-
52
- 1. **概览**:元素总数、可操作数、按类型分布(button / field / grid / tab / link)。
53
- 2. **值得注意的发现**(这是盘点的价值所在,不要只报数字):
54
- - 渲染时隐藏或禁用的元素(可能受权限/状态控制)
55
- - `runtime-only` 元素(动态创建,静态分析发现不了)
56
- - `source-only` 元素(声明了但用户看不到)
57
- - grid 的行操作和列信息
58
- 3. 文件路径,提示 JSON 里的 `locator` 可直接用于 Playwright 脚本。
59
-
60
- ## 输出格式
61
-
62
- JSON 结构:
63
-
64
- ```json
65
- {
66
- "url": "...", "title": "...", "scannedAt": "...",
67
- "summary": { "total": 42, "byType": {"button": 8, "field": 12}, "actionable": 35,
68
- "hiddenAtRuntime": 2, "runtimeOnly": 3, "sourceOnly": 1 },
69
- "dialogs": [{"trigger": "openChild", "title": "新增窗口", "pagePath": "xxx.html"}],
70
- "elements": [
71
- { "type": "button", "label": "新增", "locator": "xpath=//a[contains(@class,'mini-button')]...",
72
- "visible": true, "enabled": true, "evidence": "both", "frame": "main" }
73
- ]
74
- }
75
- ```
76
-
77
- `dialogs` 仅 `--source` 模式产出(从 script 中解析 openTopDialog 调用)。
78
-
79
- ## 边界与须知
80
-
81
- - 「可操作」判定 = 可见(非 display:none、有尺寸)且可用(非 disabled)。默认输出过滤不可见元素,`--include-hidden` 可保留。
82
- - 脚本不做交互(不点按钮、不开弹窗)。弹窗/二级页面内的元素需把对应 URL 再扫一遍,或先用 `--headed` 观察交互路径。
83
- - 登录启发式覆盖常见登录页;复杂登录(验证码、SSO 跳转)失败时,让用户手动登录后用 `--storage-state` 重试。
84
- - 元素去重键是 `type|label|locator`,同 label 不同类型控件不会合并。
85
- - **已知覆盖范围**(已验证于真实 epoint MiniUI 页面):
86
- - 按钮:MiniUI `a.mini-button`、epoint `a.cond-srh-btn`(搜索按钮)、原生 `button`、`[role=button]`
87
- - 字段:`div[label]` 包装的 MiniUI 控件(textbox/combobox/buttonedit/datepicker/spinner)、原生 `input/select/textarea`
88
- - Grid:`mini-datagrid` `.mini-grid-headertxt` 列名 + `[data-tooltip]` 行操作图标
89
- - 标签页:`.mini-tabs li`
90
- - 噪音过滤:无 label 的分页跳页输入框(`pagination-number`)等无业务意义输入不输出
91
- - **双证据实现说明**:源码侧使用 `DOMParser` 解析 HTML(不执行脚本),得到纯声明态 DOM;运行时侧用 Playwright 渲染;两者按 type+label 比对后标注 `both / runtime-only / source-only`。`setContent` 方案会执行内联脚本导致 evidence 误标,已废弃。
1
+ ---
2
+ name: page-element-scan
3
+ description: 使用时机:当用户想盘点页面元素、摸清页面有哪些按钮和字段、生成页面元素清单、为自动化测试/用例编写摸清页面结构时使用,即使用户只给了一个 URL 也应主动使用本技能。基于 Playwright 实时渲染识别页面中的可操作元素(按钮、表单字段、列表行操作、标签页、链接等),输出带定位符和可见/可用状态的元素清单(JSON + Markdown)。可与页面源 html 交叉比对,标注「静态有渲染无」「渲染有静态无」的差异证据。
4
+ version: 2026-08-20+6cf6d2e
5
+ ---
6
+
7
+ # page-element-scan — 页面可操作元素识别
8
+
9
+ ## 用途
10
+
11
+ 对指定页面做一次「可操作元素盘点」:用 Playwright 真实渲染页面,提取所有用户可操作的元素,并为每个元素给出 Playwright 可用的定位符。
12
+
13
+ 与静态读 html 源码的区别:运行时渲染才能回答「这个元素用户到底看不看得到、点不点得动」。 MiniUI 等框架会把声明式标记渲染成复杂 DOM,权限控制会让部分元素渲染时隐藏,脚本会动态创建新元素——这些只有渲染结果能告诉我们。如果用户同时提供了页面源 html,本技能会做双证据比对:
14
+
15
+ - `both`:源码声明且渲染确认 —— 最可信
16
+ - `runtime-only`:渲染时才出现(脚本动态创建)—— 静态分析会漏掉
17
+ - `source-only`:源码声明但渲染时不可见(权限隐藏/逻辑移除)—— 对最终用户不可操作
18
+
19
+ ## 输入收集
20
+
21
+ 开始扫描前,向用户确认以下信息(能从上下文推断就不要再问):
22
+
23
+ 1. **目标 URL**(必填)。本地 html 文件用 `file:///` 绝对路径。
24
+ 2. **登录方式**(如需要):账号密码,或已有的 Playwright storage-state 文件。若当前目录是胶水/e2e 项目且有 `.env`(含 `LOGIN_SYSTEM_URL` / `LOGIN_USERNAME` / `LOGIN_PASSWORD`),直接读取复用,不要问用户要密码。
25
+ 3. **源 html 路径**(可选):有就给 `--source`,触发双证据比对。
26
+ 4. **输出位置**:默认 `./page-elements/` 下,`<页面名>.json` + `<页面名>.md`。
27
+
28
+ ## 执行步骤
29
+
30
+ 1. 确定一个安装了 playwright 的工作目录作为 `--project-dir`:当前项目根、或 `e2e-test/` 下任一胶水项目(它们的 node_modules 里有 playwright)。脚本用 `createRequire` 从该目录解析依赖。
31
+
32
+ 2. 运行扫描脚本(路径相对于本技能目录):
33
+
34
+ ```bash
35
+ node <技能目录>/scripts/scan-page.mjs \
36
+ --url "<目标URL>" \
37
+ --out "<输出>.json" --md "<输出>.md" \
38
+ --project-dir "<含playwright的目录>"
39
+ ```
40
+
41
+ 按需追加:
42
+ - 登录:`--login-url <登录页> --login-user <账号> --login-pass <密码>`(启发式填用户名密码框);启发式失败时用 `--login-user-selector` / `--login-pass-selector` / `--login-submit-selector` 显式指定。
43
+ - 登录态复用:`--storage-state <state.json>`。
44
+ - 双证据:`--source <页面源html>`。
45
+ - 渲染不充分时加大 `--wait 3000`;调试时 `--headed` 看浏览器实际行为。
46
+
47
+ 3. 脚本默认扫描主 frame 和所有子 frame(epoint 页面常嵌 iframe),每个元素的 `frame` 字段标注来源;跨域 frame 会自动跳过。
48
+
49
+ ## 结果呈现
50
+
51
+ 扫描完成后读 JSON,向用户汇报:
52
+
53
+ 1. **概览**:元素总数、可操作数、按类型分布(button / field / grid / tab / link)。
54
+ 2. **值得注意的发现**(这是盘点的价值所在,不要只报数字):
55
+ - 渲染时隐藏或禁用的元素(可能受权限/状态控制)
56
+ - `runtime-only` 元素(动态创建,静态分析发现不了)
57
+ - `source-only` 元素(声明了但用户看不到)
58
+ - grid 的行操作和列信息
59
+ 3. 文件路径,提示 JSON 里的 `locator` 可直接用于 Playwright 脚本。
60
+
61
+ ## 输出格式
62
+
63
+ JSON 结构:
64
+
65
+ ```json
66
+ {
67
+ "url": "...", "title": "...", "scannedAt": "...",
68
+ "summary": { "total": 42, "byType": {"button": 8, "field": 12}, "actionable": 35,
69
+ "hiddenAtRuntime": 2, "runtimeOnly": 3, "sourceOnly": 1 },
70
+ "dialogs": [{"trigger": "openChild", "title": "新增窗口", "pagePath": "xxx.html"}],
71
+ "elements": [
72
+ { "type": "button", "label": "新增", "locator": "xpath=//a[contains(@class,'mini-button')]...",
73
+ "visible": true, "enabled": true, "evidence": "both", "frame": "main" }
74
+ ]
75
+ }
76
+ ```
77
+
78
+ `dialogs` 仅 `--source` 模式产出(从 script 中解析 openTopDialog 调用)。
79
+
80
+ ## 边界与须知
81
+
82
+ - 「可操作」判定 = 可见(非 display:none、有尺寸)且可用(非 disabled)。默认输出过滤不可见元素,`--include-hidden` 可保留。
83
+ - 脚本不做交互(不点按钮、不开弹窗)。弹窗/二级页面内的元素需把对应 URL 再扫一遍,或先用 `--headed` 观察交互路径。
84
+ - 登录启发式覆盖常见登录页;复杂登录(验证码、SSO 跳转)失败时,让用户手动登录后用 `--storage-state` 重试。
85
+ - 元素去重键是 `type|label|locator`,同 label 不同类型控件不会合并。
86
+ - **已知覆盖范围**(已验证于真实 epoint MiniUI 页面):
87
+ - 按钮:MiniUI `a.mini-button`、epoint `a.cond-srh-btn`(搜索按钮)、原生 `button`、`[role=button]`
88
+ - 字段:`div[label]` 包装的 MiniUI 控件(textbox/combobox/buttonedit/datepicker/spinner)、原生 `input/select/textarea`
89
+ - Grid:`mini-datagrid` 的 `.mini-grid-headertxt` 列名 + `[data-tooltip]` 行操作图标
90
+ - 标签页:`.mini-tabs li`
91
+ - 噪音过滤:无 label 的分页跳页输入框(`pagination-number`)等无业务意义输入不输出
92
+ - **双证据实现说明**:源码侧使用 `DOMParser` 解析 HTML(不执行脚本),得到纯声明态 DOM;运行时侧用 Playwright 渲染;两者按 type+label 比对后标注 `both / runtime-only / source-only`。`setContent` 方案会执行内联脚本导致 evidence 误标,已废弃。
@@ -55,15 +55,18 @@ function assertSymlink(linkPath, targetPath) {
55
55
  }
56
56
  }
57
57
 
58
- // ep-stage scope 下四个 skill 的包内真相源目录。
58
+ // ep-stage scope 下全部六个 skill 的包内真相源目录。
59
59
  // 注:Task 0.2 已 git mv 改名 3 个 skill 目录(glue-* 前缀化)。
60
60
  // - glue-create-project:Task 2.1 已同步 SKILL.md frontmatter name → 'glue-create-project'。
61
61
  // - glue-generate-testcase:Task 3.2 已同步 SKILL.md frontmatter name → 'glue-generate-testcase'。
62
62
  // - glue-run-test:Task 4.3 已同步 SKILL.md frontmatter name → 'glue-run-test'。
63
+ // 2026-08-20:补登 code-list-generate / page-element-scan(此前漏登,frontmatter 不受校验)。
63
64
  const skillSources = [
65
+ { name: 'code-list-generate', source: path.join(epStageRoot, 'code-list-generate') },
64
66
  { name: 'glue-create-project', source: path.join(epStageRoot, 'glue-create-project') },
65
67
  { name: 'glue-generate-testcase', source: path.join(epStageRoot, 'glue-generate-testcase') },
66
68
  { name: 'glue-run-test', source: path.join(epStageRoot, 'glue-run-test') },
69
+ { name: 'page-element-scan', source: path.join(epStageRoot, 'page-element-scan') },
67
70
  { name: 'recording-to-glue', source: path.join(epStageRoot, 'recording-to-glue') },
68
71
  ];
69
72
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * calibrate 子命令:运行时校准回路(A 支柱)。
3
+ *
4
+ * 输入 page-element-scan 产出的 elementmap.json(运行时真实控件形态),按确定性规则
5
+ * 改写 contract.json5 的组件类型与定位符槽位,使静态装配与运行时实据对齐。
6
+ *
7
+ * 契约写入纪律(双写入者):contract 只允许 assembler(静态事实)与本 calibrator
8
+ * (运行时实据)两个写入者,均机器执行 + 账本落盘(calibration-report.json)+
9
+ * 过同一 validateContract 门禁;Agent 不手编 contract。
10
+ *
11
+ * 校准规则表(高置信自动改,低置信落 unresolvedSlots 人审):
12
+ * - R1-R4 查询字段 label 命中且 controlType 映射为白名单类型 → 改 component + 重建 slot;
13
+ * - R5 label 命中但 controlType 未识别 → 不改契约,unresolvedSlots += unsupported_query_component;
14
+ * - R6 searchFields label 在 elementmap 未命中 → unresolvedSlots += query_field_not_found;
15
+ * - R7 导出按钮定位符与 elementmap 实据不一致 → 用实据覆盖 exportButton;
16
+ * - R8 elementmap 无「查询/搜索」按钮 → unresolvedSlots += search_submit_unverified;
17
+ * - R9 elementmap 存在导出面板证据(待选列/已选列)→ 注入 exportContainer 语义锚点。
18
+ *
19
+ * 默认 dry-run(只打印变更计划);--write 才落盘并级联重生成 spec / 重渲染 testcase.md。
20
+ * 组件类别变化导致用例文案变化时,该用例 reviewStatus 拨回 needs_review(机器改语义必人审)。
21
+ */
22
+ import type { UnresolvedSlotRecord } from '../contracts/glue-contract-v1.js';
23
+ /**
24
+ * calibrate 子命令参数。
25
+ */
26
+ export type CalibrateArgs = {
27
+ /** testcase.json5 路径 */
28
+ testcase: string;
29
+ /** elementmap.json 路径(缺省 <caseDir>/.ep-stage/elementmap.json) */
30
+ elementmap?: string;
31
+ /** true 时落盘并级联重生成;缺省 dry-run 只打印变更计划 */
32
+ write: boolean;
33
+ };
34
+ /** 校准变更记录。 */
35
+ export type CalibrationChange = {
36
+ slotPath: string;
37
+ from: string;
38
+ to: string;
39
+ rule: string;
40
+ evidence: {
41
+ label?: string;
42
+ locator?: string;
43
+ controlType?: string;
44
+ };
45
+ };
46
+ /**
47
+ * calibrate 子命令产物。
48
+ */
49
+ export type CalibrateResult = {
50
+ reportPath: string;
51
+ changes: CalibrationChange[];
52
+ unresolvedAdded: UnresolvedSlotRecord[];
53
+ /** 因组件变化被拨回 needs_review 的用例 ID 清单 */
54
+ casesPatched: string[];
55
+ /** 是否已落盘(false = dry-run) */
56
+ written: boolean;
57
+ /** 是否已级联重生成 spec(用例拨回 needs_review 时跳过,待人工确认后由 prepare-tasks/run 补齐) */
58
+ specRegenerated: boolean;
59
+ };
60
+ /**
61
+ * 解析 calibrate 子命令参数。
62
+ *
63
+ * @param argv - 已剥离子命令名的命令行参数。
64
+ * @returns CalibrateArgs。
65
+ */
66
+ export declare function parseCalibrateArgs(argv: string[]): CalibrateArgs;
67
+ /**
68
+ * 执行运行时校准。
69
+ *
70
+ * @param args - calibrate 参数。
71
+ * @returns CalibrateResult。
72
+ * @throws elementmap 缺失 / 校验门禁失败(不落盘)时抛错。
73
+ */
74
+ export declare function runCalibrate(args: CalibrateArgs): CalibrateResult;
75
+ //# sourceMappingURL=calibrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"calibrate.d.ts","sourceRoot":"","sources":["../../../src/cli/calibrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAUH,OAAO,KAAK,EAA0B,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AAoBrG;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,wBAAwB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,cAAc;AACd,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACtE,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B,eAAe,EAAE,oBAAoB,EAAE,CAAC;IACxC,sCAAsC;IACtC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,6BAA6B;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,wEAAwE;IACxE,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,CAmBhE;AAoED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,CA8NjE"}
@@ -0,0 +1,318 @@
1
+ /**
2
+ * calibrate 子命令:运行时校准回路(A 支柱)。
3
+ *
4
+ * 输入 page-element-scan 产出的 elementmap.json(运行时真实控件形态),按确定性规则
5
+ * 改写 contract.json5 的组件类型与定位符槽位,使静态装配与运行时实据对齐。
6
+ *
7
+ * 契约写入纪律(双写入者):contract 只允许 assembler(静态事实)与本 calibrator
8
+ * (运行时实据)两个写入者,均机器执行 + 账本落盘(calibration-report.json)+
9
+ * 过同一 validateContract 门禁;Agent 不手编 contract。
10
+ *
11
+ * 校准规则表(高置信自动改,低置信落 unresolvedSlots 人审):
12
+ * - R1-R4 查询字段 label 命中且 controlType 映射为白名单类型 → 改 component + 重建 slot;
13
+ * - R5 label 命中但 controlType 未识别 → 不改契约,unresolvedSlots += unsupported_query_component;
14
+ * - R6 searchFields label 在 elementmap 未命中 → unresolvedSlots += query_field_not_found;
15
+ * - R7 导出按钮定位符与 elementmap 实据不一致 → 用实据覆盖 exportButton;
16
+ * - R8 elementmap 无「查询/搜索」按钮 → unresolvedSlots += search_submit_unverified;
17
+ * - R9 elementmap 存在导出面板证据(待选列/已选列)→ 注入 exportContainer 语义锚点。
18
+ *
19
+ * 默认 dry-run(只打印变更计划);--write 才落盘并级联重生成 spec / 重渲染 testcase.md。
20
+ * 组件类别变化导致用例文案变化时,该用例 reviewStatus 拨回 needs_review(机器改语义必人审)。
21
+ */
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import JSON5 from 'json5';
25
+ import { validateContract } from '../validation/contract.js';
26
+ import { validateTestcase } from '../validation/testcase.js';
27
+ import { renderUnifiedTestcaseMarkdown } from '../testcase/testcase-markdown.js';
28
+ import { buildGlueExecutionFields } from '../testcase/contract-testcase-assembler.js';
29
+ import { resolveQueryComponent } from '../testcase/query-component-map.js';
30
+ import { resolveRunPaths } from './run.js';
31
+ import { runSpec } from './spec.js';
32
+ /**
33
+ * 解析 calibrate 子命令参数。
34
+ *
35
+ * @param argv - 已剥离子命令名的命令行参数。
36
+ * @returns CalibrateArgs。
37
+ */
38
+ export function parseCalibrateArgs(argv) {
39
+ const result = {};
40
+ const flags = new Set();
41
+ const normalizedArgv = argv.filter((arg) => arg !== '--');
42
+ for (let index = 0; index < normalizedArgv.length; index++) {
43
+ const key = normalizedArgv[index];
44
+ if (key === '--write') {
45
+ flags.add('write');
46
+ continue;
47
+ }
48
+ const value = normalizedArgv[index + 1];
49
+ if (!key?.startsWith('--') || !value || value.startsWith('--')) {
50
+ throw new Error(`无效参数对: ${key ?? '<empty>'}`);
51
+ }
52
+ result[key.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())] = value;
53
+ index += 1;
54
+ }
55
+ if (!result.testcase)
56
+ throw new Error('缺少 --testcase');
57
+ return { testcase: result.testcase, elementmap: result.elementmap, write: flags.has('write') };
58
+ }
59
+ /** 24 小时(elementmap 新鲜度阈值)。 */
60
+ const ELEMENTMAP_STALE_MS = 24 * 60 * 60 * 1000;
61
+ /**
62
+ * 按校准后组件类型重建 searchConditions 槽位(与装配器槽位形状镜像)。
63
+ *
64
+ * @param kind - 校准后组件类型。
65
+ * @param field - 字段名。
66
+ * @param label - 中文标签。
67
+ * @param runtimeLocator - elementmap 实据定位器(可选)。
68
+ * @param existingSlot - 既有槽位(listbox 的 createSelections 等需保留)。
69
+ * @returns 新槽位对象。
70
+ */
71
+ function buildCalibratedSlot(kind, field, label, runtimeLocator, existingSlot) {
72
+ const inputLocator = runtimeLocator ?? `xpath=//div[@label='${label}']//input`;
73
+ if (kind === 'input') {
74
+ return {
75
+ component: 'input',
76
+ field,
77
+ label,
78
+ seedValue: existingSlot?.seedValue ?? '测试数据',
79
+ strategy: 'timestamp_prefix',
80
+ addLocator: inputLocator,
81
+ editLocator: inputLocator,
82
+ listLocator: inputLocator,
83
+ };
84
+ }
85
+ if (kind === 'datepicker') {
86
+ return {
87
+ component: 'datepicker',
88
+ field,
89
+ label,
90
+ listLocator: inputLocator,
91
+ searchAssertion: 'none',
92
+ triggerSearch: true,
93
+ };
94
+ }
95
+ if (kind === 'tree') {
96
+ return {
97
+ component: 'tree',
98
+ field,
99
+ label,
100
+ ...(runtimeLocator ? { treeLocator: runtimeLocator } : {}),
101
+ nodeStrategy: 'first_leaf',
102
+ searchOnSelect: true,
103
+ searchAssertion: 'none',
104
+ };
105
+ }
106
+ return {
107
+ component: 'listbox',
108
+ field,
109
+ label,
110
+ valueStrategy: 'first_non_placeholder',
111
+ skipValues: ['请选择'],
112
+ searchAssertion: 'none',
113
+ // 保留装配期按 add 表单推导的 createSelections;校准不改写 create 侧语义
114
+ createSelections: existingSlot?.createSelections ?? [],
115
+ };
116
+ }
117
+ /**
118
+ * 执行运行时校准。
119
+ *
120
+ * @param args - calibrate 参数。
121
+ * @returns CalibrateResult。
122
+ * @throws elementmap 缺失 / 校验门禁失败(不落盘)时抛错。
123
+ */
124
+ export function runCalibrate(args) {
125
+ const testcasePath = path.resolve(args.testcase);
126
+ const paths = resolveRunPaths(testcasePath);
127
+ const elementmapPath = args.elementmap
128
+ ? path.resolve(args.elementmap)
129
+ : path.join(paths.caseDir, '.ep-stage', 'elementmap.json');
130
+ if (!existsSync(elementmapPath)) {
131
+ throw new Error(`elementmap.json 不存在: ${elementmapPath}。请先经 /glue-run-test Step 4a(登录 + 全 frame 扫描)产出新鲜 elementmap。`);
132
+ }
133
+ const scannedAt = JSON5.parse(readFileSync(elementmapPath, 'utf8'));
134
+ if (scannedAt.scannedAt && Date.now() - Date.parse(scannedAt.scannedAt) > ELEMENTMAP_STALE_MS) {
135
+ console.warn(`警告:elementmap 扫描时间 ${scannedAt.scannedAt} 距今超过 24h,建议重新扫描后再校准。`);
136
+ }
137
+ const elementmap = scannedAt;
138
+ const elements = (elementmap.elements ?? []).filter((e) => e.visible !== false);
139
+ const contractPath = path.join(paths.caseDir, '.ep-stage', 'contract.json5');
140
+ if (!existsSync(contractPath)) {
141
+ throw new Error(`contract.json5 不存在: ${contractPath}(应先由 testcase 子命令产出)`);
142
+ }
143
+ const contract = JSON5.parse(readFileSync(contractPath, 'utf8'));
144
+ const testcase = validateTestcase(JSON5.parse(readFileSync(testcasePath, 'utf8')));
145
+ const changes = [];
146
+ const unresolvedAdded = [];
147
+ const casesPatched = [];
148
+ contract.unresolvedSlots = contract.unresolvedSlots ?? [];
149
+ const pushUnresolved = (record) => {
150
+ const duplicated = contract.unresolvedSlots.some((slot) => slot.kind === record.kind && slot.slotPath === record.slotPath);
151
+ if (!duplicated) {
152
+ contract.unresolvedSlots.push(record);
153
+ unresolvedAdded.push(record);
154
+ }
155
+ };
156
+ const crudSlots = contract.scenario.skeletonSlots.crud;
157
+ const searchConditions = Array.isArray(crudSlots.searchConditions)
158
+ ? crudSlots.searchConditions
159
+ : [];
160
+ crudSlots.searchConditions = searchConditions;
161
+ // R1-R6:查询字段组件形态校准
162
+ for (const searchField of contract.scenario.searchFields) {
163
+ const label = searchField.label;
164
+ const hit = elements.find((e) => e.type === 'field' && e.label === label);
165
+ if (!hit) {
166
+ pushUnresolved({
167
+ slotPath: `scenario.searchFields[${label}]`,
168
+ kind: 'query_field_not_found',
169
+ reason: `查询字段「${label}」在运行时 elementmap 中未命中(隐藏/未渲染/label 漂移)。`,
170
+ suggestedAction: 'human_review',
171
+ detectedAt: 'calibrate',
172
+ evidence: { source: 'elementmap', ref: path.basename(elementmapPath) },
173
+ });
174
+ continue;
175
+ }
176
+ const controlType = hit.controlType ?? '';
177
+ const resolution = resolveQueryComponent({ component: controlType });
178
+ if (resolution.source === 'unrecognized') {
179
+ pushUnresolved({
180
+ slotPath: `scenario.searchFields[${label}]`,
181
+ kind: 'unsupported_query_component',
182
+ rawComponent: resolution.raw,
183
+ reason: `查询字段「${label}」运行时组件 ${resolution.raw} 不在 glue 白名单映射表。`,
184
+ suggestedAction: 'extend_component_library',
185
+ detectedAt: 'calibrate',
186
+ evidence: { source: 'elementmap', ref: hit.locator ?? label },
187
+ });
188
+ continue;
189
+ }
190
+ const nextKind = resolution.kind;
191
+ if (nextKind && nextKind !== searchField.component) {
192
+ const ruleByKind = {
193
+ input: 'R1', listbox: 'R2', datepicker: 'R3', tree: 'R4',
194
+ };
195
+ changes.push({
196
+ slotPath: `scenario.searchFields[${label}].component`,
197
+ from: searchField.component,
198
+ to: nextKind,
199
+ rule: ruleByKind[nextKind],
200
+ evidence: { label, locator: hit.locator, controlType },
201
+ });
202
+ searchField.component = nextKind;
203
+ // 重建 searchConditions 槽位(按 label 找既有槽位保留需保留字段)
204
+ const existingIndex = searchConditions.findIndex((c) => c.label === label);
205
+ const existingSlot = existingIndex >= 0 ? searchConditions[existingIndex] : undefined;
206
+ const nextSlot = buildCalibratedSlot(nextKind, searchField.field, label, hit.locator, existingSlot);
207
+ if (existingIndex >= 0) {
208
+ searchConditions[existingIndex] = nextSlot;
209
+ }
210
+ else {
211
+ searchConditions.push(nextSlot);
212
+ }
213
+ }
214
+ }
215
+ // R7:导出按钮定位符用 elementmap 实据覆盖
216
+ const exportButtonHit = elements.find((e) => e.type === 'button' && e.label === '导出');
217
+ const exportLocators = (contract.scenario.skeletonSlots.export.locators ??= {});
218
+ if (exportButtonHit?.locator && exportLocators.exportButton !== exportButtonHit.locator) {
219
+ changes.push({
220
+ slotPath: 'scenario.skeletonSlots.export.locators.exportButton',
221
+ from: exportLocators.exportButton ?? '<未配置>',
222
+ to: exportButtonHit.locator,
223
+ rule: 'R7',
224
+ evidence: { label: '导出', locator: exportButtonHit.locator },
225
+ });
226
+ exportLocators.exportButton = exportButtonHit.locator;
227
+ }
228
+ // R8:查询/搜索按钮在 elementmap 中缺席 → 人审(树节点选择后仍要求显式提交)
229
+ const hasSearchSubmit = elements.some((e) => e.type === 'button' && (e.label === '查询' || e.label === '搜索'));
230
+ if (!hasSearchSubmit) {
231
+ pushUnresolved({
232
+ slotPath: 'scenario.skeletonSlots.crud.locators.listSearchSubmit',
233
+ kind: 'search_submit_unverified',
234
+ reason: 'elementmap 中未发现「查询/搜索」按钮;datepicker/input 条件的 triggerSearch 与 tree 的 searchOnSelect 语义需人工确认。',
235
+ suggestedAction: 'human_review',
236
+ detectedAt: 'calibrate',
237
+ evidence: { source: 'elementmap', ref: path.basename(elementmapPath) },
238
+ });
239
+ }
240
+ // R9:导出面板证据(待选列/已选列)→ 注入 exportContainer 语义锚点
241
+ const hasPanelEvidence = elements.some((e) => e.label === '待选列' || e.label === '已选列');
242
+ if (hasPanelEvidence && !exportLocators.exportContainer) {
243
+ const containerAnchor = "xpath=//div[.//*[normalize-space()='待选列'] and .//*[normalize-space()='已选列']]";
244
+ changes.push({
245
+ slotPath: 'scenario.skeletonSlots.export.locators.exportContainer',
246
+ from: '<未配置>',
247
+ to: containerAnchor,
248
+ rule: 'R9',
249
+ evidence: { label: '待选列/已选列' },
250
+ });
251
+ exportLocators.exportContainer = containerAnchor;
252
+ }
253
+ // 用例级联:组件类别变化影响 glue 用例语义时的诚实拨回(机器改语义必人审)。
254
+ // - read 用例:按新组件类型重算 steps/assertion,文案有变化才拨回 needs_review;
255
+ // - create/update/delete 用例:组件变为 datepicker/tree 后用例计划规则已不再产出它们
256
+ // (datepicker/tree 只产 read),无条件拨回 needs_review,由人审后重跑 testcase 子命令重整。
257
+ const changedFields = new Map(changes
258
+ .filter((change) => change.slotPath.startsWith('scenario.searchFields['))
259
+ .map((change) => [
260
+ change.slotPath.slice('scenario.searchFields['.length, -1),
261
+ change.to,
262
+ ]));
263
+ const searchFieldLabels = contract.scenario.searchFields.map((field) => field.label);
264
+ for (const testcaseCase of testcase.cases) {
265
+ if (testcaseCase.generationStrategy !== 'glue')
266
+ continue;
267
+ if (!testcaseCase.queryKey)
268
+ continue;
269
+ const nextKind = changedFields.get(testcaseCase.queryKey);
270
+ if (!nextKind)
271
+ continue;
272
+ const isReadCase = testcaseCase.title.includes(' read');
273
+ if (!isReadCase) {
274
+ testcaseCase.reviewStatus = 'needs_review';
275
+ casesPatched.push(testcaseCase.caseId);
276
+ continue;
277
+ }
278
+ const rebuilt = buildGlueExecutionFields('read', testcaseCase.queryKey, nextKind, false, searchFieldLabels);
279
+ if (rebuilt.steps !== testcaseCase.steps || rebuilt.assertion !== testcaseCase.assertion) {
280
+ testcaseCase.steps = rebuilt.steps;
281
+ testcaseCase.assertion = rebuilt.assertion;
282
+ testcaseCase.reviewStatus = 'needs_review';
283
+ casesPatched.push(testcaseCase.caseId);
284
+ }
285
+ }
286
+ // 门禁:改写后的 contract 必须过同一 validateContract(含白名单交叉规则),失败则不落盘。
287
+ validateContract(contract);
288
+ validateTestcase(testcase);
289
+ const report = {
290
+ schemaVersion: 'ep-stage-calibration-report/v1',
291
+ calibratedAt: new Date().toISOString(),
292
+ elementmapRef: elementmapPath,
293
+ changes,
294
+ unresolvedAdded,
295
+ testcaseCasesPatched: casesPatched.map((caseId) => ({ caseId })),
296
+ written: args.write,
297
+ };
298
+ const reportPath = path.join(paths.caseDir, '.ep-stage', 'calibration-report.json');
299
+ if (!args.write) {
300
+ console.log(JSON5.stringify(report, null, 2));
301
+ return { reportPath, changes, unresolvedAdded, casesPatched, written: false, specRegenerated: false };
302
+ }
303
+ writeFileSync(contractPath, `${JSON5.stringify(contract, null, 2)}\n`, 'utf8');
304
+ let specRegenerated = false;
305
+ if (casesPatched.length > 0) {
306
+ // 用例语义被机器改写 → reviewStatus 已拨回 needs_review,spec 门禁会阻断重生成;
307
+ // 本轮只落 contract/testcase/账本,spec 待人工确认用例后由 prepare-tasks/run 重生成。
308
+ writeFileSync(testcasePath, `${JSON5.stringify(testcase, null, 2)}\n`, 'utf8');
309
+ writeFileSync(path.join(paths.caseDir, 'testcase.md'), renderUnifiedTestcaseMarkdown(testcase, contract), 'utf8');
310
+ }
311
+ else {
312
+ // 级联重生成 spec(无条件覆写;AI 追加段由 run 时从 ai-snippets.json5 幂等重建)
313
+ runSpec({ testcase: testcasePath, out: paths.specPath });
314
+ specRegenerated = true;
315
+ }
316
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
317
+ return { reportPath, changes, unresolvedAdded, casesPatched, written: true, specRegenerated };
318
+ }