atlas-project-harness 0.2.0-beta.1 → 0.2.0-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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v0.2.0-beta.3] - 2026-10-23
8
+
9
+ ### 🩹 `value` 断言读可见文本(D133)
10
+
11
+ * **假红修复**:Ant Design Select 交互后会在触发器内注入 `aria-live` 播报 span(`opacity:0`),旧实现用 `innerText` 把选中文案读两遍,`value` 断言在「先选择再断言」的用例上必红而页面本身正常。
12
+ * **定案**:新增 `atlasVisibleText` 助手(临时移出不可见子树 → 读 `innerText` → `try/finally` 恢复 style),`atlasValueOf` 与 `atlasExpectValue` 统一走它;原生表单元素的 `toHaveValue` 路径不变。
13
+ * **钉**:`test_value_reads_visible_text_excluding_a11y_mirror` 从生成物抽助手在 Node 里真求值(隐藏播报不计入、style 恢复、发射面无裸 `innerText` 回退)。
14
+
15
+ ### ⌨️ E2E 新增 `type` 逐字输入动词(D134)
16
+
17
+ * **场景**:带掩码 / 受控输入的控件(如 Ant Design RangePicker 的日期输入)用 `fill` 一次性写 DOM 不会触发组件内部解析,模型状态不提交;逐条 `press` 每次只发单键、无法形成连续输入链。
18
+ * **定案**:`type <tid> <text>` → `locator.pressSequentially(text)`,逐字触发 `keydown/keypress/input`;**不先清空**原值(替换语义用 `fill`),值必填;动作词表 13 → 14。
19
+ * **钉**:样例守恒 +1、`test_type_verb_emits_press_sequentially_without_clearing`(发射形态 / 空值中止 / `[N]`)+ 校验器动作侧 `test_e2e_index_type_is_action_not_assertion`;变异 **M134**。
20
+
21
+ ---
22
+
23
+ ## [v0.2.0-beta.2] - 2026-10-22 - 2026-10-22
24
+
25
+ ### ✨ E2E hash 查询参数(D132)
26
+
27
+ * **`hash参数:` + `e2e.query_preset.hash_keys`**:HashRouter 页面 `#` 之后的查询前提;`goto` 值内联 hash 查询,生成器把它注入守卫导航的 hash 段,base query 仍留在 `#` 之前;零步基线与 `参数:` 同纪律不施加;路径门两侧去双查询后比对。
28
+ * **钉**:生成器 +6(守卫/步骤双发射、零步基线排除、未白名单、死声明、值形态、`url_merge` 原语);校验器 +7(正向、未白名单、白名单外、死声明、值形态、未声明 hash 键、双载体)。`shared/gates.md` 的 `tests/test_validators.py` 计数已同步 **138**。
29
+
30
+ ---
31
+
7
32
  ## [v0.2.0-beta.1] - 2026-10-21
8
33
 
9
34
  ### ✨ 评审 / 覆盖 / 事故三条反馈闭环(D128–D131)
package/DESIGN.md CHANGED
@@ -598,6 +598,9 @@ adapters:
598
598
  | D129 | 独立审查报告契约扩展为 **`schema_version: 2`**,引入缺陷指纹去重、存量代码标记、处置留痕与轮次上限(2026-10-21;`shared/independent-review.md` §3/§4;`_support/review_policy.py`、`scripts/validate_review_report.py`、`scripts/cli_review_convert.py`、`scripts/gen_e2e_scripts.py`) | ① **病灶**:① 原契约状态映射硬死(有任一 Critical 即 FAIL),**无法区分改动引入的新缺陷与存量旧代码问题**;Agent 评审实测 59% 为既有存量代码,导致微小改动因存量问题被无限阻断;② 缺乏 finding 去重指纹,导致相同问题跨轮重复刷分;③ 缺乏轮次上限,实测出现连夜 50+ 轮死循环;④ 缺乏显式处置闭环(disposition),误报(false positive)无法由人或架构签字排除。② **定案**:① 设立单源内核 `_support/review_policy.py`;② `review.json` 引入 `schema_version: 2`;`checks[]` 扩充 `fingerprint`(排除行号的稳定哈希)、`pre_existing`(bool)、`disposition`(open/fixed/false_positive/intentional/ignored)、`verification`;③ 状态映射:阻断性 Critical = `Critical ∧ pre_existing!=true ∧ disposition未关闭`;`pre_existing=true` 的 Critical 降级为 WARN 下限(记录存量,不静默放行);带签字与取证的 `false_positive` 降级为 WARN;重复 fingerprint ⇒ 校验器 FAIL;④ 轮次收敛:round >= 4 必须提供 `--escalate-to` 与 `--escalate-reason` 升级复议,否则拒绝放行;⑤ 首轮后支持 `--delta-only` 差量复审;⑥ 缺省 `schema_version: 1` 维持既有全阻断逻辑,存量历史报告 100% 兼容。③ **被否**:把 disposition 做成无需签字的口头开关(开严重安全后门);由模型自审关闭 Critical(违背独立审查原则,必须人/架构签字)。④ **钉与变异**:`tests/test_review_report.py` +4、`tests/test_cli_review_convert.py` +3(含 `test_fingerprint_stable_across_line_numbers`:行号漂移后指纹必须不变、内容不同指纹必须不同——评审抓到 CLI 恒产 `path:12-15` 而旧正则剥不掉 ⇒ 去重门永不命中)、`tests/test_gen_e2e_scripts.py` +1;变异 **M130**(摘掉 pre_existing 豁免退回原写死 FAIL ⇒ 钉红)、**M130b**(摘 `validate_review_report` 的 fingerprint 去重段 ⇒ 重复指纹钉红)、**M130c**(`clean_target_file` 行号正则退回 `:\d+$`,剥不掉 CLI 恒产的 `path:12-15` ⇒ 指纹稳定性钉红),四段协议(基线绿→注入红→逐字节还原→复绿)全部通过、还原 sha256 逐字节一致。全量测试通过。 |
599
599
  | D130 | E2E 环引入 **前端适配版 C01-C14 覆盖维度矩阵** 与缺口对账(2026-10-21;`rings/e2e/reference.md` §5.1/§5.2/§5.5/§8.2;`_support/case_block.py`、`validators/validate_e2e_index.py`、`templates/prd/domain.md`、`templates/e2e/case-shard.md`) | ① **病灶**:原「AC 反向覆盖门」仅能回答「每条 AC 是否至少被一条用例指向」,**无法回答每条 AC 的异常流、边界值、权限、会话恢复、幂等、状态转换等高危场景是否覆盖**;导致 Agent 生成大量表面正向用例,真正易崩场景系统性遗漏。② **定案**:① 前端适配裁剪版 C01-C14 场景维度闭集(C01正常流、C02异常流、C03边界值、C04权限、C05并发、C06重试、C07审计、C08会话恢复、C09一致性、C10依赖容错、C11幂等、C12输入校验、C13状态转换、C14安全);② 用例分片块体增加可选数组字段 `覆盖维度:`,单源解析器 `_support/case_block.py` 纳入键空间;③ 域级 PRD 验收标准表增加可选第 4 列 `适用覆盖维度`(如 `C01, C02`),前三列保持不变;④ 校验器 `validators/validate_e2e_index.py` 增加「AC × 覆盖维度」对账项:已声明但未全量覆盖 ⇒ WARN;未知维度词 ⇒ WARN;未声明第 4 列的项目 ⇒ N/A 兜底,绝不产生破坏存量的永久红门;⑤ 控制台 snapshot case 对象透传 `dimensions` 字段。③ **被否**:在 PRD 新建第 4 张手工对账表(严重违反 single-source 契约,且会与 AC 唯一性定义门发生行号碰撞);对所有 AC 强制 14 维度全覆盖(引发无脑生成几十条形式用例的膨胀灾难)。④ **钉与变异**:`tests/test_validators.py` 增加 `test_e2e_index_coverage_dimensions_pass_and_warn`(N/A / WARN / PASS 三态验证);变异 **M131**(摘掉 dimensions 聚合回落全 PASS ⇒ 钉红),还原逐字节一致。全量测试通过。 |
600
600
  | D131 | 收口契约引入 **变更风险定档、Always-Human 路径、修复验证 7 检查点与事故追问闭环**(2026-10-21;`shared/closeout.md` §2 步骤 3/4/11;`duty-review/README.md` §2;`templates/reviews/README.md`) | ① **病灶**:① 收口阶段缺乏系统性风险定档,高危底层改动(如权限、路由、CI、包依赖)容易被 Agent 自述误导而跳过严格人工亲测;② 缺陷修复任务(bug fix)常直接修改业务代码并自报已修复,缺乏「先失败后转绿」的 Red First 证明与边界/回归核对,导致同类问题反复逃逸;③ 线上/联调事故复盘停留在修代码,未追问「哪一道门本该接住」,事故没有转化为常态化回归用例或变异钉。② **定案**:① `shared/closeout.md` 补齐步骤 3「变更风险定档与人工审批」:三档风险矩阵,明确 `auth / permission / routing / CI / package deps / shared contracts` 为 Always-Human 永远人工路径,严禁 Agent 代勾放行;② 补齐步骤 4「缺陷修复验证 7 检查点」(复现明确、Red First 先失败测试、最小 diff、边界值、状态机完整、回归范围界定、闭环证据留存);③ 步骤 11 与 `duty-review/README.md` 引入事故追问机制:每个确认逃逸缺陷必须在 `bugs.md` 标注原因,并在关闭前补齐 E2E 回归用例或 M 系列变异钉;④ 提供 `templates/reviews/README.md` 代码审查指引轻量模板(≤10 行,聚焦失败特征与证据链)。③ **被否**:用文件名/正则做脆弱的机器强制路径阻断脚本(极易被重命名或参数绕过,且易对合法改动造成假阻断);由 Agent 自动签署审批(违背人类作为最终交付负责人的第一原则)。④ **钉与变异**:`test_canonical_consistency.py` 计数与跨文件一致性全量跑绿。 |
601
+ | D132 | 新增 **E2E hash 查询参数契约**(`hash参数:` + `e2e.query_preset.hash_keys`)(2026-10-22;`rings/e2e/reference.md` §5.5/§5.7/§8.2;`shared/stack-profile.md` §2;`_support/case_block.py` / `_support/url_merge.py` / `scripts/gen_e2e_scripts.py` / `validators/validate_e2e_index.py`) | ① **病灶**:HashRouter 页面的 `useSearchParams` 只读 `#` 之后的查询段;D109 `参数:` 只覆盖 `#` 之前的常规查询,`会话:` 是 sessionStorage,导致快照页这类「`#` 后查询前提」用例无合法写法,只能手补生成物(重跑即丢)。② **定案**:`hash参数:` 只声明键,值内联在 `goto` 的 hash 查询串;白名单 `query_preset.hash_keys`;守卫导航把同一段 hash 查询追加到 hash 段(`append_hash_query`),base query 仍在 `#` 前;零步基线不施加;路径门两侧 `strip_queries` 后比对;同键不得跨 `参数:`/`会话:`/`hash参数:` 双写。③ **被否**:把 `#` 后查询硬塞进 `参数:`(会丢 hash 段 / 与 base query 合并语义冲突);项目侧手补生成物(不可重生成,属已否的 D98 类补丁)。④ **钉**:生成器 6 条 + 校验器 7 条(含 `test_hash_param_carried_to_guard_and_goto`、`test_zero_step_baseline_carries_no_hash_param`、`test_hash_query_merge_helpers` 与正向/未白名单/白名单外/死声明/值形态/未声明 hash 键/双载体);`tests/test_validators.py` 计数 138 已登记 `shared/gates.md`。 |
602
+ | D133 | `value` 断言/探针改读**可见文本**,排除触发器内的视觉隐藏 a11y 镜像(2026-10-23;`scripts/gen_e2e_scripts.py` 发射点;契约 §5.5 `value` 行) | ① **病灶**:`value` 对非原生控件用 `innerText` 读触发器文本。Ant Design Select 在用户交互后向触发器内注入 `aria-live="polite"` 的无障碍播报 span(`width:0;height:0;opacity:0`),其文本与选中项相同;`innerText` 不排除视觉隐藏节点 ⇒ 选中文案被读两遍(现场:`模式一…\n模式一…`),同一标签的 `value` 断言在交互后必红,而交互前同断言正常(基线 green / 动作后 red 的假红)。契约本体写的是「触发器**可见**文本」,实现与契约不符。② **定案**:新增发射期助手 `atlasVisibleText(loc)`:采值前遍历触发器子树,用 `checkVisibility({opacityProperty:true,visibilityProperty:true})`(缺 API 时退 `getComputedStyle`)判定,把不可见节点的 `style` 暂存后置 `display:none !important`,读 `innerText` 后**按逆序逐字节恢复 style**(`try/finally` 覆盖异常路径),保留 `innerText` 的排版归一半径;`atlasValueOf`(探针,含三态语义)与 `atlasExpectValue`(正式断言,改用 `expect.poll` 保留轮询)统一走该助手;原生 `input/select/textarea` 的 `toHaveValue` 路径不变。③ **被否**:按 `aria-live` / `aria-hidden` 选择器硬排除(只治 Ant Design 一处,其他组件库的视觉隐藏节点仍会污染;`aria-hidden` 还可能承载可见文本);读 `textContent`(会把 `display:none` 的隐藏下拉项也算进来,扩大假绿面);对读数做去重(同一文案合法重复出现时无法区分,属语义猜测)。④ **钉与变异**:`tests/test_gen_e2e_scripts.py::test_value_reads_visible_text_excluding_a11y_mirror` —— 从生成物抽出助手源码,在 Node 里以假 DOM **真求值**:可见项 + `opacity:0` 的同文案播报项 ⇒ 只返回一次;采值后隐藏节点 `style` 恢复为 `null`;同时钉发射面含 `atlasVisibleText`、不再出现 `toHaveText(v, { useInnerText: true })`。 |
603
+ | D134 | 新增 E2E **`type` 逐字输入动词**(2026-10-23;契约 §5.5;`scripts/gen_e2e_scripts.py` / `validators/validate_e2e_index.py`) | ① **病灶**:Playwright `fill` 一次性写 DOM、只触发 `input`,不触发带掩码 / 受控输入组件的内部解析(现场:Ant Design RangePicker 日期输入,文本可见但保存 payload 无 `startTime/endTime`);逐条 `press` 每次先 focus、只发单键,无法形成连续输入链(实测同样不提交)⇒ 项目侧只能留人工或用 `_support` 钩子代输入(步骤不可见、绕过 DSL)。② **定案**:`type <tid> <text>` → `locator.pressSequentially(text)`,逐字触发 `keydown/keypress/input`,**不先清空**原值(替换语义用 `fill`);值必填,空值 ⇒ 生成器报错中止;动作词表 13 → 14,校验器词表同步,样例守恒表 +1。③ **被否**:用 N 条 `press` 拼输入(不可读且实测不提交);`_support` 钩子代输入(步骤不可见、绕过 case DSL);移除产品侧掩码(UX 变更、超出 E2E 能力面)。④ **钉**:`test_type_verb_emits_press_sequentially_without_clearing`(发射形态 + 空值中止 + `[N]` 定位)与 `test_e2e_index_type_is_action_not_assertion`(step 侧放行 / expected 侧拒收);变异 **M134**(摘生成器 `type` 分支 ⇒ 钉红)。 |
601
604
 
602
605
 
603
606
 
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Atlas — 践行 Project Harness 的全流程 AI 编程工作流
2
2
 
3
3
  <p align="left">
4
- <img src="https://img.shields.io/badge/version-0.2.0--beta.1-blue.svg" alt="Version" />
4
+ <img src="https://img.shields.io/badge/version-0.2.0--beta.3-blue.svg" alt="Version" />
5
5
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
6
6
  <img src="https://img.shields.io/badge/tests-passing-success.svg" alt="Tests" />
7
7
  <a href="https://sunwenzhe-git.github.io/atlas/"><img src="https://img.shields.io/badge/docs-VitePress-green.svg" alt="Docs" /></a>
@@ -45,8 +45,10 @@ strip_inline_comment = load_module("stack_profile").strip_inline_comment
45
45
  # 键空间 = `rings/e2e/reference.md` §5.5 用例块字段(消费方按需读取)。
46
46
  # `参数`(2026-10-18,`DESIGN.md` D109):宿主注入型 URL 参数的**键清单**(值内联在 step 的
47
47
  # `goto` 查询串里)——不在键空间内的键会被按**标量**入表(`key = None`),其后 `- 项` 行跟丢。
48
+ # `hash参数`(2026-10-22,HashRouter 场景):`#` 之后 hash 查询串的**键清单**(值同样内联在
49
+ # `goto` 的 hash 查询里)——与 `参数:` 分载体,值来自 `window.location.hash` 的查询段。
48
50
  # `覆盖维度`(2026-10-21,`DESIGN.md` D130):前端适配版 C01-C14 场景维度列表。
49
- _BLOCK_KEYS = ("pages", "precondition", "seed", "mock", "step", "expected", "testid", "落点", "会话", "参数", "覆盖维度")
51
+ _BLOCK_KEYS = ("pages", "precondition", "seed", "mock", "step", "expected", "testid", "落点", "会话", "参数", "hash参数", "覆盖维度")
50
52
 
51
53
 
52
54
  def parse_block(body: str) -> dict:
@@ -81,6 +81,17 @@ def query_of(target: str) -> str:
81
81
  return target.partition("#")[0].partition("?")[2]
82
82
 
83
83
 
84
+ def hash_query_of(target: str) -> str:
85
+ """`target` 的 **hash 查询串**(`/a#/h?k=v` ⇒ `k=v`;无 hash / 无查询 ⇒ 空串)。
86
+
87
+ 与 `query_of` 分载体:HashRouter 页面的 `useSearchParams` 读的是 hash 内的查询段,
88
+ 常规查询串(`#` 之前)由 `query_of` 负责。两者不得混用。
89
+ """
90
+ if "#" not in target:
91
+ return ""
92
+ return target.partition("#")[2].partition("?")[2]
93
+
94
+
84
95
  def query_keys(target: str) -> list[str]:
85
96
  """`target` 查询串里的键(按出现顺序去重)——「键须已声明」门与生成器守卫取值**共用本单源**。"""
86
97
  out: list[str] = []
@@ -107,12 +118,57 @@ def append_query(target: str, q: str) -> str:
107
118
  return f"{joined}#{frag}" if sep else joined
108
119
 
109
120
 
121
+ def hash_query_keys(target: str) -> list[str]:
122
+ """`target` 的 hash 查询键(按出现顺序去重)——`hash参数:` 声明门与生成器守卫取值共用。"""
123
+ out: list[str] = []
124
+ for part in hash_query_of(target).split("&"):
125
+ if not part:
126
+ continue
127
+ k = part.partition("=")[0].strip()
128
+ if k and k not in out:
129
+ out.append(k)
130
+ return out
131
+
132
+
133
+ def append_hash_query(target: str, q: str) -> str:
134
+ """把查询串 `q` 追加到 `target` 的 **hash 查询段**(`/a#/h` ⇒ `/a#/h?q`)。
135
+
136
+ 只处理已带 hash 的目标;没有 hash 时原样返回(不私造一条 hash 路由,调用方应响亮失败)。
137
+ 已有 hash 查询 ⇒ 与 `q` 求并集(既有在前、`q` 在后,**同键 ⇒ `q` 胜**)。
138
+ """
139
+ if not q or "#" not in target:
140
+ return target
141
+ head, _, frag = target.partition("#")
142
+ fpath, fsep, fq = frag.partition("?")
143
+ merged = q if not fsep else union_query(fq, q)
144
+ return f"{head}#{fpath}?{merged}"
145
+
146
+
110
147
  def strip_query(target: str) -> str:
111
- """去掉查询串、保留 hash(`/a?k=v#/h` ⇒ `/a#/h`)——供路径门按**路径形态**匹配(`_route_hit`)。"""
112
- if "?" not in target:
148
+ """去掉**常规查询串**、保留 hash 段(含 hash 查询)(`/a?k=v#/h?x=1` ⇒ `/a#/h?x=1`)。
149
+
150
+ 只处理 `#` 之前的查询;hash 内的查询由 `strip_hash_query` 单独处理。
151
+ """
152
+ head, sep, frag = target.partition("#")
153
+ if "?" in head:
154
+ head = head.partition("?")[0]
155
+ return f"{head}#{frag}" if sep else head
156
+
157
+
158
+ def strip_hash_query(target: str) -> str:
159
+ """去掉 **hash 查询串**、保留 hash 路径(`/a#/h?k=v` ⇒ `/a#/h`)。"""
160
+ head, sep, frag = target.partition("#")
161
+ if not sep or "?" not in frag:
113
162
  return target
114
- head, _, rest = target.partition("?")
115
- return head + "#" + rest.partition("#")[2] if "#" in rest else head
163
+ return f"{head}#{frag.partition('?')[0]}"
164
+
165
+
166
+ def strip_queries(target: str) -> str:
167
+ """同时去掉常规查询串与 hash 查询串,保留 hash 路径(`/a?t=1#/h?k=v` ⇒ `/a#/h`)。
168
+
169
+ 供 `goto` / `守卫` 路径门按**路径形态**匹配(页面表 `路由` 列是路径形态)。顺序无关。
170
+ """
171
+ return strip_hash_query(strip_query(target))
116
172
 
117
173
 
118
174
  def origin_of(base: str) -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-project-harness",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.3",
4
4
  "description": "践行 Project Harness 的全流程 AI 编程工作流治理引擎",
5
5
  "bin": {
6
6
  "atlas": "bin/atlas.js"
@@ -254,6 +254,7 @@ testid:
254
254
  |---|---|---|
255
255
  | 动作 | `click` | 点击 testid。**缺省追加 `{ force: true }`**(2026-10-16 补,issue #R4):动机 = 真实 UI 里悬浮遮罩 / 动画 / 父容器事件拦截会造出动作执行超时的**假红**,把「元素真的在那」这件事误报成「点不动」。**适用边界(硬)**:被测属性本身是「可操作性 / 不可交互 / 用户不可达」(例:「被遮罩覆盖点的点不动」「动画中不可点」)的用例**不得**依赖该默认 —— `force` 会跳过 *receives events / stable* 检查,使该属性**无法被证伪**(假绿通道)。**步级开关**:`click <tid> native` ⇒ 该步按运行器原生语义执行(**不**追加 `force`),保留 `.first()` 的 strict-mode 修复。**值域封闭**:第三位**仅允许** `native` 或缺省,其它值 ⇒ 校验器 FAIL + 生成器报错中止 |
256
256
  | | `fill` | 在输入框填入值 |
257
+ | | `type` | **逐字键入**(2026-10-23 补,`DESIGN.md` D134):把值按字符逐个键入(Playwright `pressSequentially`),触发 `keydown / keypress / input` 的真实按键链;**不先清空**原值(替换语义用 `fill`)。用于带掩码 / 受控输入的控件(如 Ant Design RangePicker 的日期输入):`fill` 一次性写 DOM 不会触发组件内部解析,模型状态不更新;`press` 只发单键、无法形成连续输入链。值必填,空值 ⇒ 生成器报错中止 |
257
258
  | | `select` | 在该控件上**选中 label 的那一项**(2026-10-04 实现无关化,`3509 §B173-1` / `3508 §173-§174`:发射器产出按元素自适应的 helper —— 原生 `<select>` 走 `selectOption`;否则视作 trigger+listbox 组合(如 Radix/shadcn Select),点开触发器后**按选项自身的标签**点选。**语义只定「行为」不定「DOM 机制」**——组件实现(原生 / 基座)不被 E2E 应然约束。**选项定位口径(2026-10-19,`DESIGN.md` D112)**:**不得以「可访问名全等」定位选项**——组件常把描述文案塞进选项内,其可访问名因此 = 标签 + 描述,全等必失配(现场:ant Select 的选项名为 `文本对话基于LLM的对话型交互…`,用例卡在 30s 超时)。三级降级 = **① `title` 属性全等 → ② 可见文本前缀 → ③ 名称包含**;**命中不唯一即拒绝并列出可见选项**(宁可响亮失败,也不 `.first()` 静默选错项 = 假绿通道);候选只取**几何可见**的元素,仅非原生分支适用。**候选集(2026-10-20,`DESIGN.md` D122)**:ARIA 的 `[role=option]` / `[role=menuitem]` 之外,须含组件库自身的可见选项类名(`[class*="select-item-option"]`——Antd Select 的可见项只有 `title`、无 `role=option`,role 项是零宽 a11y 镜像;`[class*=]` 兼容自定义前缀)。**就绪门(2026-10-19,`DESIGN.md` D117)**:点开触发器后**不得**以全局候选集的 `attached` 作就绪判据——页面上常驻的 `[role=menuitem]`(顶部导航等)会让它秒满足 ⇒ 采样落在下拉尚未定位(零盒)的窗口内,候选只剩环境项(现场失败文案 `可见选项=["应用市场",…]`;失败快照反证触发器已 `[expanded]`、listbox 内选项齐)。改为**按命中态轮询采样**(轮询原语 = `expect.poll`,与页面就绪锚点同一机制,**不引入固定等待**):**0 命中 = 未就绪** ⇒ 轮询至 `ATLAS_WAIT_TIMEOUT_MS` 上限;**>1 命中 = 歧义** ⇒ **立即**拒绝(D112 语义不变);唯一 ⇒ 定。) |
258
259
  | | `check` / `uncheck` | 勾选 / 取消勾选 |
259
260
  | | `press` | 按键(值 = 键名) |
@@ -270,7 +271,7 @@ testid:
270
271
  | | `visible` / `hidden` | 可见 / 不可见 |
271
272
  | | `enabled` / `disabled` | 可用 / 不可用(**不要求可见**,与 `visible` 正交) |
272
273
  | | `checked` / `unchecked` | 复选框已勾选 / 未勾选(**不要求可见**,与 `enabled` 同理) |
273
- | | `value` | 控件**当前值**等于值(2026-10-04 实现无关化,同 `select`:原生表单元素读 input 值;trigger+listbox 组合读触发器可见文本);值写作 `""`(或 `(空)`)时表示**空串**,用于「清空后为空」类断言 |
274
+ | | `value` | 控件**当前值**等于值(2026-10-04 实现无关化,同 `select`:原生表单元素读 input 值;trigger+listbox 组合读触发器可见文本);值写作 `""`(或 `(空)`)时表示**空串**,用于「清空后为空」类断言。**「可见文本」排除视觉隐藏的 a11y 镜像**(2026-10-23,`DESIGN.md` D133):组件库会在触发器内注入 `opacity:0` 的播报节点(如 Ant Design Select 交互后的 `aria-live` span),取可见文本时临时移出布局、读完恢复;不得用裸 `innerText` 把同一文案读两遍 |
274
275
  | | `countOptions` | 该元素内的 `option` 子元素个数等于值(整数),用于「枚举选项个数」类断言;**结构动词、仅适用原生 `<select>`**(选项集合在 DOM 结构上是其子元素;trigger+listbox 组合的选项在打开前不存在于 DOM,本动词对其无语义) |
275
276
  | | `attr` | 元素属性 `<name>` 的值等于 `<value>`(**不要求可见**,与 `enabled` / `checked` 同理:属性可读性与渲染可见性正交);值段写作 `<name> <value>`,`name` 取**首个空白前**的 token、其余全部为 `value`(可含空格);属性不存在 ⇒ 取值为空 ⇒ 对**非空**期望值恒失败(2026-09-25 补,依据 `3509 §B35`)。**值写作 `""`(或 `(空)`)= 断言「该属性缺席或为空串」**(2026-10-19 补,`DESIGN.md` D116):`getAttribute` 对缺席属性返回 `null`(**不是** `""`)⇒ 发射侧一律 `?? ''` 归一后轮询比对;`toHaveAttribute(name, "")` **不适用**(实测缺席时判 FAIL = 半通状态)。该形态是**否定属性**的表达面(「该入口**不带**某标记」类 AC 的机器判据)|
276
277
  | | `delta` | **数值增量**(相对断言,2026-10-07 补,`issues` P5):值段 = 带符号整数 `±N`。语义 = 行动前采样该元素文本中的**首个整数**、行动后复读,两者差值必须恰为 `±N`。真栈单 run 累加态下「待生成 +1」类断言的合法形态 —— 绝对值断言(`text X 待生成 3 条`)在同前提的兄弟用例上只可能首跑者成立。渲染为**轮询断言**(node `expect.poll` / python 行动后复读);采样点 = **第一个提交性动作之前**(与 `unchanged:` 先例同点,准备步骤不污染基准)。**不得**与 `initial:` / `unchanged:` / `after:` 叠写、用例必须有 `step`(均 ⇒ 校验器 FAIL,生成器中止双保险);**不参与零步基线**(它没有「初始态成立 / 不成立」语义)。首例 = `E2E-PROJECTS-014`(拆分后项目级待生成递增,2026-10-07 转正) |
@@ -303,6 +304,13 @@ testid:
303
304
  - **边界(明示)**:本通道只表达**载入时**注入;「运行中改地址栏查询串」**无通道**(`refresh` 后的地址不含该参数——这正是「禁用态由会话内快照承载」类用例的被测属性,与 §5.5 `会话:` 分工)。
304
305
  - **与 §5.7 的边界(硬)**:本通道只承载**宿主注入型前提**(身份 / 租户 / 管控 / 灰度 / 预览壳类)——与 `会话:` 同族、**同白名单纪律**;**数据实体状态仍只能用真实界面动作达到**。**未声明或不在白名单的查询键一律 FAIL** ⇒ 原靶(「不得用调试 / 演示参数直达某个数据态」)由此守住(2026-10-18 重靶的落点,见 §8.2 门表与 `DESIGN.md` D109)。
305
306
  - **双载体禁止**:同一键**不得**同时出现在 `会话:` 与 `参数:`(两个载体写同一键 = 前提不可解释)⇒ **FAIL**。
307
+ - **`hash参数:`(可选,2026-10-22 补,`DESIGN.md` D132)**:**HashRouter 载入参数前提**。行形态 `- <键>`(一词一条,**只声明键、无值**)。
308
+ - **为何要它**:HashRouter 页面的 `useSearchParams` 读的是 `#` 之后的查询段;`参数:` 只覆盖 `#` 之前的常规查询(`window.location.search`),`会话:` 是 sessionStorage,都送不到这类消费面。值内联写在 `step` 的 `goto` 的 **hash 查询串**里(`/a#/h?k=v`)。
309
+ - **守卫导航**:取该用例首个带 hash 查询的 `goto`,把同一段 hash 查询追加到守卫导航的 hash 段(`url_merge.append_hash_query`);base query 仍在 `#` 之前,二者不互相吞掉。
310
+ - **键必须在项目侧白名单内**(`stack-profile.yaml` 的 `e2e.query_preset.hash_keys`,见 `shared/stack-profile.md` §2):白名单外 / 整段未声明 / 声明了却没被任何 `goto` 的 hash 查询用到(**死声明**)⇒ **文本期 FAIL + 生成器中止**。
311
+ - **与 §5.6 R9 的关系**:**零步基线不施加 `hash参数:`**(同 `参数:`)⇒ 依赖该前提的断言在基线默认态下不成立,R9 自动满足。
312
+ - **路径门**:`goto` / `守卫` 的路径匹配先去掉常规查询与 hash 查询,再与页面表 `路由` 比对(页面表可以带宿主 query 或 hash 段)。
313
+ - **双载体禁止**:同一键**不得**同时出现在 `参数:`、`会话:`、`hash参数:`(多个载体写同一键 = 前提不可解释)⇒ **FAIL**。
306
314
  - **`守卫:`(可选,2026-10-16 补,issue #23)**:**元素齐全守卫导航用的具体路径**(标量)。
307
315
  - **为何要它**:页面表 `路由` 列按 §3.1 只承载**业务段**(带参页面的真实路由形如 `/x/:id`,而该列写 `/x`)——它是**模式**,不是可导航地址。而生成器的元素齐全守卫要 `page.goto` 一次才能数元素 ⇒ 直接拿 `路由` 列导航,带参页面会落到路由匹配不上的地址(页面不渲染 ⇒ 守卫在 `goto` 后即红,用例自身的带参 `goto` 来不及生效)。**同一列被两种相反语义的用途消费**(一处要模式、一处要具体地址)是本字段的由来。
308
316
  - **值**:一条具体路径;必须命中页面表 `路由` 列(口径同 §8.2 的 `goto` 路径门:精确,或**非根路由**的子路径前缀命中)——分片仍不得私藏路径。
@@ -536,11 +544,15 @@ testid:
536
544
  | 分片块 | 字段齐(`id`/`pages`/`precondition`/`step`/`expected`/`testid`);块 `id` 属于索引;无孤儿(分片有索引外用例);无死链(索引用例在分片缺失) |
537
545
  | `pages` 合法性 | 每页均为已知页面(索引**页面表**的页 slug);`page` ∈ `pages` |
538
546
  | 反向视图一致 | 反向视图 == 由「主表 + 分片」推导的集合 |
539
- | `goto` 查询键须已声明 | `goto` **可以**带查询串,但每个键必须 ① ∈ `e2e.query_preset.keys` **∧** ② ∈ 本用例 `参数:` 声明;缺一即 **FAIL**(**2026-10-18 重靶**,`DESIGN.md` D109;原门为「`goto` 不带查询参数」一刀切)。**原靶仍在 FAIL 面内**:未声明的查询键(原型专有 `?state=` 那类调试参数 = 「数据态直达」形态)照样红 |
547
+ | `goto` 查询键须已声明 | `goto` **可以**带常规查询串,但每个键必须 ① ∈ `e2e.query_preset.keys` **∧** ② ∈ 本用例 `参数:` 声明;缺一即 **FAIL**(**2026-10-18 重靶**,`DESIGN.md` D109;原门为「`goto` 不带查询参数」一刀切)。**原靶仍在 FAIL 面内**:未声明的查询键(原型专有 `?state=` 那类调试参数 = 「数据态直达」形态)照样红 |
540
548
  | `参数` 声明有效 | `参数:` 项须为**纯键**(无 `=` / 无空白)、去重、键 ∈ `e2e.query_preset.keys`、且须被本用例某个 `goto` 用到(**死声明** ⇒ FAIL);整段未声明而出现 `参数:` 行 ⇒ FAIL(2026-10-18,D109) |
541
549
  | `参数` 值形态 | `goto` 查询值须命中 URL 安全集(禁空白 / 引号(含 `'`)/ 反斜杠 / `&` / `#` / `=`);越形态 ⇒ **FAIL**(生成物是 `page.goto('<url>')` 的**单引号拼接面**;2026-10-18,D109) |
542
550
  | `会话`·`参数` 同键不叠写 | 同一用例同键同时出现在 `会话:` 与 `参数:` ⇒ **FAIL**(前提不可解释,R7 同族;2026-10-18,D109) |
543
- | `goto` 路径 ∈ 页面表 | `step` 的 `goto <路径>` 必须命中索引页面表「路由」列:精确命中,或**非根路由**的子路径前缀命中(`/projects/x` 匹配 `/projects`;根路由 `/` 仅精确);越界或**缺路径参数**即 **FAIL**(2026-09-30 补——实测原型时代路径漂移 `goto /home` vs 页面表 `/`,真栈 404;页面表是页↔路由唯一真相源,分片不得私藏路径) |
551
+ | `goto` hash 查询键须已声明 | `goto` 的 **hash 查询串**(`#` 之后)每个键必须 ① ∈ `e2e.query_preset.hash_keys` ∧ ② ∈ 本用例 `hash参数:` 声明;缺一即 **FAIL**(2026-10-22,D132) |
552
+ | `hash 参数` 声明有效 | `hash参数:` 项须为纯键、去重、键 ∈ `e2e.query_preset.hash_keys`、且须被本用例某个 `goto` 的 hash 查询用到(**死声明** ⇒ FAIL);整段未声明而出现 `hash参数:` 行 ⇒ FAIL(2026-10-22,D132) |
553
+ | `hash 参数` 值形态 | `goto` 的 hash 查询值须命中 URL 安全集(同 `参数` 值形态);越形态 ⇒ **FAIL**(2026-10-22,D132) |
554
+ | `hash参数`·`其他载体` 同键不叠写 | 同一用例同键同时出现在 `hash参数:` 与 `参数:`/`会话:` ⇒ **FAIL**(前提不可解释;2026-10-22,D132) |
555
+ | `goto` 路径 ∈ 页面表 | `step` 的 `goto <路径>` 必须命中索引页面表「路由」列:精确命中,或**非根路由**的子路径前缀命中(`/projects/x` 匹配 `/projects`;根路由 `/` 仅精确);比对前**两侧都去掉常规查询与 hash 查询**(`url_merge.strip_queries`,2026-10-22 D132);越界或**缺路径参数**即 **FAIL**(2026-09-30 补——实测原型时代路径漂移 `goto /home` vs 页面表 `/`,真栈 404;页面表是页↔路由唯一真相源,分片不得私藏路径) |
544
556
  | `auth` 取值合法 | 块键 `auth` 只允许 `preset` \| `none`(缺省 `preset`);白名单外 **FAIL**(文本期挡 + 生成器中止双保险;2026-09-30 补) |
545
557
  | `waitFor` 属动作 | `waitFor` 出现在 `expected` ⇒ **FAIL**(它是动作、不是终态;`expected` 是终态集合,§5.6 R9) |
546
558
  | 同一 testid 断言不自相矛盾 | 同一条用例对同一 testid 既断言可见/有文案、又断言不可见 → **FAIL**(§5.6 R7) |
@@ -5,9 +5,9 @@
5
5
  改为「操作脚本」):
6
6
  * 读 `product/e2e/cases/<page>.md` 的 `atlas-case` 块,**一页一脚本**;
7
7
  * 生成**可运行的操件脚本**:每条用例一个测试——`goto(<页面 URL>)` → 按 `step`
8
- **逐行执行动作**(click / fill / select / check / uncheck / press / hover /
9
- refresh / goto)→ 按 `expected` **逐行执行断言**(text / contains / count /
10
- visible / hidden / enabled / disabled / value);`#` 之后的中文说明作为注释保留;
8
+ **逐行执行动作**(动词表见契约 §5.5;含 click / fill / type / press 等)→ 按
9
+ `expected` **逐行执行断言**(text / contains / count / visible / hidden /
10
+ enabled / disabled / value);`#` 之后的中文说明作为注释保留;
11
11
  * **动词解析失败即中止**:`step` / `expected` 出现词表外的动词 → 报错并列出违规行,
12
12
  不得静默跳过或降级为注释;
13
13
  * `testid:` 数组作为**元素齐全守卫**:每用例运行前按**自动等待**断言其列出的 testid 全部
@@ -93,7 +93,7 @@ APP_LOGIN_MODES = ("real", "fabricated", "none")
93
93
  MOCK_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
94
94
 
95
95
  # 分片 step / expected 的动词词表(契约 `rings/e2e/reference.md` §5.5,2026-09-23 定)
96
- ACTION_VERBS = ("click", "fill", "select", "check", "uncheck", "press", "hover", "refresh", "goto", "waitFor", "download", "upload", "dispatch")
96
+ ACTION_VERBS = ("click", "fill", "select", "check", "uncheck", "press", "type", "hover", "refresh", "goto", "waitFor", "download", "upload", "dispatch")
97
97
  ASSERT_VERBS = ("text", "contains", "count", "countOptions", "visible", "hidden", "enabled", "disabled", "checked", "unchecked", "value", "attr", "delta")
98
98
  NO_TESTID_VERBS = ("refresh", "goto") # 这两个动词第二个 token 不是 testid(goto 的值是路径)
99
99
  # 契约 §5.5(2026-10-16 补,issue #R4):`click` / `hover` 默认追加 `{ force: true }`
@@ -440,6 +440,13 @@ def ts_case(case: dict, url: str, base: str = "", esm: bool = False) -> str:
440
440
  # `url` 在调用侧已并入 base query(幂等);`守卫:` 是未合并的具体路径 —— 两者都按
441
441
  # 「追加到自身 query」处理(插在 hash 之前),随后的一次 merge 会把 base 去重并上来。
442
442
  _guard_target = url_merge.append_query(_guard_target, _inject_q)
443
+ _inject_hq = case.get("_inject_hash_query") or ""
444
+ if _inject_hq:
445
+ # `hash参数:` 是 HashRouter 的 `#` 后查询前提;守卫导航必须带同一段,否则守卫读的是
446
+ # 无前提的 DOM(快照页会停在「缺少快照标识」错误态)。没有 hash 路由 ⇒ 响亮失败。
447
+ if "#" not in _guard_target:
448
+ raise ValueError(f"{cid}: `hash参数:` 需要守卫/页面路由含 hash 段")
449
+ _guard_target = url_merge.append_hash_query(_guard_target, _inject_hq)
443
450
  guard_url = url_merge.merge_base_query(_guard_target, base)
444
451
  # 契约 §5.5 / §7.1("元素齐全守卫按自动等待判定"):守卫 id 面 = **该用例 `testid:` 应然清单**
445
452
  # (实测:首步为 `goto`/`refresh` 的用例取 `step[0]` 会得到**空表** ⇒ 守卫静默消失)。
@@ -519,6 +526,15 @@ def ts_case(case: dict, url: str, base: str = "", esm: bool = False) -> str:
519
526
  actions.append(f"await {tid_locator(tid)}.uncheck();")
520
527
  elif verb == "press":
521
528
  actions.append(f"await {tid_locator(tid)}.press({json.dumps(value)});")
529
+ elif verb == "type":
530
+ # 契约 §5.5(D134,2026-10-23):逐字键入 —— 触发 keydown/keypress/input 的真实
531
+ # 按键链,供带掩码 / 受控输入的控件使用(`fill` 一次性写 DOM 不触发组件内部解析,
532
+ # 如 Ant Design RangePicker 的日期输入)。**不先清空原值**;替换语义用 `fill`。
533
+ if not value.strip():
534
+ raise ValueError(
535
+ f"{cid}: `type` 需要输入文本,得空值:{s!r}(契约 §5.5)")
536
+ actions.append(
537
+ f"await {tid_locator(tid)}.pressSequentially({json.dumps(value, ensure_ascii=False)});")
522
538
  elif verb == "hover":
523
539
  actions.append(
524
540
  f"await {tid_locator(tid)}.hover("
@@ -738,9 +754,11 @@ def ts_assert_expr(line: str) -> str:
738
754
  return f"(async () => (await {target}.locator('option').count()) === Number({v}))()"
739
755
  if verb == "visible":
740
756
  return f"(async () => await tsVisible(page, {tid_param}))()"
757
+ # is*() 对缺失元素会等挂载(30s 挂起)——先 count 守卫。
758
+ # enabled/disabled/checked 缺失 ⇒ false;hidden/unchecked 缺失 ⇒ null,
759
+ # 否则「元素不存在」会被反转成 true,基线误判为恒真假阳性。
741
760
  if verb == "hidden":
742
- return f"(async () => !(await tsVisible(page, {tid_param})))()"
743
- # is*() 对缺失元素会等挂载(30s 挂起)——先 count 守卫:缺失 ⇒ false(基线探针安全跳过)
761
+ return f"(async () => {{ const c = await page.getByTestId({t}).count(); if (c <= {nth_idx}) return null; return !(await tsVisible(page, {tid_param})); }})()"
744
762
  if verb == "enabled":
745
763
  return f"(async () => (await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isEnabled()))()"
746
764
  if verb == "disabled":
@@ -748,7 +766,7 @@ def ts_assert_expr(line: str) -> str:
748
766
  if verb == "checked":
749
767
  return f"(async () => (await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isChecked()))()"
750
768
  if verb == "unchecked":
751
- return f"(async () => !((await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isChecked())))()"
769
+ return f"(async () => {{ const c = await page.getByTestId({t}).count(); if (c <= {nth_idx}) return null; return !(await {nth_locator}.isChecked()); }})()"
752
770
  if verb == "value":
753
771
  if value.strip() in ('""', "(空)"):
754
772
  v = json.dumps("", ensure_ascii=False)
@@ -1367,6 +1385,13 @@ def main() -> int:
1367
1385
  qpreset_keys: set[str] = set()
1368
1386
  if isinstance(qpreset_cfg, dict):
1369
1387
  qpreset_keys = {str(k).strip() for k in (qpreset_cfg.get("keys") or []) if str(k).strip()}
1388
+ # 契约 §5.5 `hash参数:`(2026-10-22,HashRouter):`#` 之后 hash 查询串的键清单。
1389
+ # 与 `参数:` 分载体、分白名单(`query_preset.hash_keys`),守卫导航同样取首跳。
1390
+ hash_qpreset_keys: set[str] = set()
1391
+ if isinstance(qpreset_cfg, dict):
1392
+ hash_qpreset_keys = {
1393
+ str(k).strip() for k in (qpreset_cfg.get("hash_keys") or []) if str(k).strip()
1394
+ }
1370
1395
  bad_inject: list[str] = []
1371
1396
  for items in cases.values():
1372
1397
  for c in items:
@@ -1419,11 +1444,73 @@ def main() -> int:
1419
1444
  f"{c['id']}: 参数键 {k!r} 已在 `参数:` 声明、但没有任何 `goto` 用到(死声明)")
1420
1445
  c["_inject_keys"] = ok_keys
1421
1446
  c["_inject_query"] = first_q
1422
- if bad_inject:
1423
- print("ERROR: `参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1424
- for b in bad_inject[:40]:
1425
- print(f" - {b}", file=sys.stderr)
1426
- print(f" 共 {len(bad_inject)} 条", file=sys.stderr)
1447
+ # 契约 §5.5 `hash参数:`(2026-10-22,HashRouter):第二遍独立校验,不能复用 `参数:`
1448
+ # 分支的 `continue`(没有常规 `参数:` 的用例同样要能声明 hash 参数)。
1449
+ bad_hash_inject: list[str] = []
1450
+ for items in cases.values():
1451
+ for c in items:
1452
+ c["_inject_hash_keys"] = []
1453
+ c["_inject_hash_query"] = None
1454
+ declared_hash = [str(x).strip() for x in (c.get("hash参数") or []) if str(x).strip()]
1455
+ if not declared_hash:
1456
+ continue
1457
+ if not hash_qpreset_keys:
1458
+ bad_hash_inject.append(
1459
+ f"{c['id']}: 出现 `hash参数:` 行,但 stack-profile 未声明 "
1460
+ "`e2e.query_preset.hash_keys`(该靶场不允许 hash 查询前提;契约 §5.5)")
1461
+ continue
1462
+ ok_hash_keys: list[str] = []
1463
+ for k in declared_hash:
1464
+ if "=" in k or any(ch.isspace() for ch in k):
1465
+ bad_hash_inject.append(
1466
+ f"{c['id']}: `hash参数:` 项须为纯键(值写在 goto 的 hash 查询串里):{k!r}")
1467
+ elif k not in hash_qpreset_keys:
1468
+ bad_hash_inject.append(
1469
+ f"{c['id']}: hash 参数键 {k!r} 不在 `e2e.query_preset.hash_keys` 白名单内"
1470
+ f"(={sorted(hash_qpreset_keys)})")
1471
+ elif k not in ok_hash_keys:
1472
+ ok_hash_keys.append(k)
1473
+ first_hq: str | None = None
1474
+ used_hash: set[str] = set()
1475
+ for s in c.get("step") or []:
1476
+ verb, _tid, value, _ = parse_line(s)
1477
+ if verb != "goto":
1478
+ continue
1479
+ keep_hash: list[str] = []
1480
+ for pair in url_merge.hash_query_of(value.strip()).split("&"):
1481
+ if not pair:
1482
+ continue
1483
+ k, _, v = pair.partition("=")
1484
+ k, v = k.strip(), v.strip()
1485
+ if k not in ok_hash_keys:
1486
+ bad_hash_inject.append(
1487
+ f"{c['id']}: `goto` hash 查询键 {k!r} 未在本用例 `hash参数:` 内声明"
1488
+ "或不在白名单内")
1489
+ continue
1490
+ if v and not url_merge.VALUE_RE.match(v):
1491
+ bad_hash_inject.append(f"{c['id']}: hash 查询值含越形态字符:{k}={v}")
1492
+ continue
1493
+ keep_hash.append(pair)
1494
+ used_hash.add(k)
1495
+ if keep_hash and first_hq is None:
1496
+ first_hq = "&".join(keep_hash)
1497
+ for k in ok_hash_keys:
1498
+ if k not in used_hash:
1499
+ bad_hash_inject.append(
1500
+ f"{c['id']}: hash 参数键 {k!r} 已在 `hash参数:` 声明、但没有任何 `goto` 用到(死声明)")
1501
+ c["_inject_hash_keys"] = ok_hash_keys
1502
+ c["_inject_hash_query"] = first_hq
1503
+ if bad_inject or bad_hash_inject:
1504
+ if bad_inject:
1505
+ print("ERROR: `参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1506
+ for b in bad_inject[:40]:
1507
+ print(f" - {b}", file=sys.stderr)
1508
+ print(f" 共 {len(bad_inject)} 条", file=sys.stderr)
1509
+ if bad_hash_inject:
1510
+ print("ERROR: `hash参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1511
+ for b in bad_hash_inject[:40]:
1512
+ print(f" - {b}", file=sys.stderr)
1513
+ print(f" 共 {len(bad_hash_inject)} 条", file=sys.stderr)
1427
1514
  return 2
1428
1515
  # upload fixture 引用闭环(契约 §5.5,B201-1,2026-10-12):值 = 相对
1429
1516
  # `product/e2e/fixtures/` 的文件路径(与 §5.8 mock 同基线、同一 fixtures 树)。
@@ -1558,6 +1645,31 @@ def main() -> int:
1558
1645
  " const l = loc.nth(nth);\n"
1559
1646
  " return (await l.isVisible()) ? l : null;\n"
1560
1647
  "}\n"
1648
+ "async function atlasVisibleText(loc: any): Promise<string> {\n"
1649
+ " // 契约 §5.5(D133,2026-10-23):`value` 读**可见文本**。组件库会在触发器内放\n"
1650
+ " // 视觉隐藏的 a11y 播报节点(Ant Design Select 交互后注入的 aria-live span,opacity:0),\n"
1651
+ " // innerText 会把同一文案读两遍(现场:模式选择器断言收到两行相同标签)⇒ 采值前把\n"
1652
+ " // 不可见子树临时移出布局,读完(含异常路径)原样恢复 style;innerText 的排版归一半径不变。\n"
1653
+ " return loc.evaluate((e: HTMLElement) => {\n"
1654
+ " const hidden: { el: HTMLElement; style: string | null }[] = [];\n"
1655
+ " for (const node of Array.from(e.querySelectorAll('*')) as HTMLElement[]) {\n"
1656
+ " const check = (node as any).checkVisibility;\n"
1657
+ " const visible = typeof check === 'function'\n"
1658
+ " ? check.call(node, { opacityProperty: true, visibilityProperty: true })\n"
1659
+ " : (() => { const s = getComputedStyle(node); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'; })();\n"
1660
+ " if (visible) continue;\n"
1661
+ " hidden.push({ el: node, style: node.getAttribute('style') });\n"
1662
+ " node.style.setProperty('display', 'none', 'important');\n"
1663
+ " }\n"
1664
+ " try { return (e.innerText || '').trim(); }\n"
1665
+ " finally {\n"
1666
+ " for (let i = hidden.length - 1; i >= 0; i--) {\n"
1667
+ " const { el, style } = hidden[i];\n"
1668
+ " if (style === null) el.removeAttribute('style'); else el.setAttribute('style', style);\n"
1669
+ " }\n"
1670
+ " }\n"
1671
+ " });\n"
1672
+ "}\n"
1561
1673
  "async function atlasValueOf(page: any, tid: string): Promise<string | null> {\n"
1562
1674
  " // 契约 §5.5(B173-1,2026-10-04):`value` 断言/探针的实现无关入口。\n"
1563
1675
  " // 原生表单元素读 input 值;否则视作 trigger+listbox 组合(如 Radix/shadcn\n"
@@ -1567,7 +1679,7 @@ def main() -> int:
1567
1679
  " const tag = await l.evaluate((e) => e.tagName.toLowerCase());\n"
1568
1680
  " return (tag === 'select' || tag === 'input' || tag === 'textarea')\n"
1569
1681
  " ? l.inputValue()\n"
1570
- " : (await l.innerText()).trim();\n"
1682
+ " : atlasVisibleText(l);\n"
1571
1683
  "}\n"
1572
1684
  "async function atlasSelect(page: any, tid: string, label: string, nth: number = 0) {\n"
1573
1685
  " // 契约 §5.5(B173-1,2026-10-04;**2026-10-19 D112** 选项定位放宽):`select` 的语义 =\n"
@@ -1628,7 +1740,10 @@ def main() -> int:
1628
1740
  " if (tag === 'select' || tag === 'input' || tag === 'textarea') {\n"
1629
1741
  " await expect(el).toHaveValue(v);\n"
1630
1742
  " } else {\n"
1631
- " await expect(el).toHaveText(v, { useInnerText: true });\n"
1743
+ " // 契约 §5.5(D133):读可见文本,保留 expect 的自动轮询语义(元素迟到/重渲染)。\n"
1744
+ " await expect.poll(async () => {\n"
1745
+ " try { return await atlasVisibleText(el); } catch { return null; }\n"
1746
+ " }, { timeout: ATLAS_WAIT_TIMEOUT_MS }).toBe(v);\n"
1632
1747
  " }\n"
1633
1748
  "}\n"
1634
1749
  "async function atlasProbeOnce(page: any, p: any): Promise<boolean | null> {\n"