{% include 'conf/static/_style.html' %} {%- set status_label = {'PASS':'通过','FAIL':'失败','ERROR':'出错','SKIPPED':'跳过','BLOCKED':'被阻塞','GAP':'缺口','UNVERIFIED':'未判定'} %} {%- set nb = against_baseline or {} %} {%- set nr = against_run or {} %} {%- set newly_base = nb.get('newly_failing', []) %} {%- set newly_run = nr.get('newly_failing', []) %}
{{ title }}
{{ run_id or 'run' }} {%- if selector %}选择器 {{ selector }}{% endif %} {%- if env %}环境 {{ env }}{% endif %} {{ generated }} 耗时 {{ '%.2f'|format(duration) }} s
{%- if verdict.status in ('fail', 'error') %} {%- elif verdict.status == 'review' %} {%- else %} {%- endif %} {{ verdict.label }}

{{ verdict.sentence }}

    {%- if newly_base or nb.get('no_passing_record') %}
  • {% if newly_base %}其中 {{ newly_base|length }} 条在基线里是通过的,这次新红{% endif %} {%- if newly_base and nb.get('no_passing_record') %};{% endif %} {%- if nb.get('no_passing_record') %}{{ nb.no_passing_record|length }} 条在基线里没有通过记录{% endif %}。
  • {%- endif %} {%- if totals.unverified %}
  • {{ totals.unverified }} 条用例通过了,但什么都没判定,它的“通过”不代表任何结论。
  • {% endif %} {%- if totals.skipped %}
  • {{ totals.skipped }} 条用例前置条件不满足,没有执行,不计入失败。
  • {% endif %} {%- if weakened and not gate_weakened %}
  • 判定变弱不会让这次运行失败;在 CI 里加 --fail-on-weakened 才会拦下。
  • {% endif %} {%- if flaky %}
  • {{ flaky|length }} 条标记为不稳定,无论这次是否通过都列在下面。
  • {% endif %}
{%- if collect_error %}
{{ collect_error }}
{% endif %}
对比基线
{% if baseline %}{{ baseline_display }}随代码提交,本机和 CI 看到的一样{% else %}无用 --update-baseline 生成并提交{% endif %}
对比运行
{% if nr %}{{ nr.run_id or '—' }}{{ '来源:传入的报告' if nr.source == 'argument' else '来源:本机缓存,只在这台机器上有' }}{% else %}无可用 --compare-with 传入一份 report.json{% endif %}
{%- if shards %}
合并自
{{ shards|length }} 个分片{% if duplicates %} · 去掉重复的 {{ duplicates }} 条{% endif %}{% for sh in shards %}{{ sh.run_id or sh.source }} · {{ sh.cases }} 条{% if not sh.ok %} · 有失败{% endif %}{% endfor %}
{%- endif %}
退出码
{{ 0 if verdict.releasable else 1 }}
{%- set fails = totals.failed + totals.error %} 用例结果 · 共 {{ totals.total }} 条 {{ fails if fails else totals.passed }}{{ '失败' if fails else '通过' }} {%- if totals.total %} {%- endif %} {{ totals.passed }} 通过 · {{ fails }} 失败 · {{ totals.unverified }} 未判定 · {{ totals.skipped }} 跳过 通过率 {{ pass_rate }}{% if pass_rate_basis.denominator %} = {{ pass_rate_basis.passed }} / {{ pass_rate_basis.denominator }}{% endif %},{{ pass_rate_note }} 判定强度{% if baseline %} · 对比基线{% endif %} {{ check_totals.passed + check_totals.failed }}条判定 {%- if weakened %} {{ weakened }} 处变弱 {%- elif baseline %}没有变弱 {%- else %}没有基线{% endif %} {{ check_totals.passed }} 成立 · {{ check_totals.failed }} 不成立 未判定 {{ totals.unverified }}条 通过了,但没有任何 Check、成立的 assert 或 pytest.raises 已声明的缺口 {{ check_totals.gap }}处 {%- if gap_list %}{{ gap_list[0].name }}{% if gap_list|length > 1 %} 等 {{ gap_list|length }} 类{% endif %}{% else %}没有用例声明 gap(){% endif %} {%- for grid in coverage[:1] %} 维度覆盖{% if grid.title %} · {{ grid.title }}{% endif %} {%- if grid.problem %} ?无法识别 {{ grid.problem }} {%- else %} {{ grid.filled }} / {{ grid.total }}格 {%- if grid.rows|length <= 4 and grid.x_labels|length <= 4 %} {% for x in grid.x_labels %}{% endfor %}{% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %}
{{ x }}
{{ row.label }}{{ (cell.cases|length ~ ' 条') if cell.cases else '无' }}
{%- endif %} {%- endif %}
{%- else %}
维度覆盖 — 没有声明 COVERAGE,看不出哪些组合没测
{%- endfor %}
{%- if baseline or nr %}

这次新红的

{%- if baseline %}
对比基线{{ baseline_display }}
基线通过,这次失败
{{ newly_base|length }}
基线里无通过记录
{{ nb.get('no_passing_record', [])|length }}
新用例
{{ nb.get('new_cases', [])|length }}
{%- if newly_base or nb.get('no_passing_record') %} {%- endif %}
{%- endif %} {%- if nr %}
对比运行{{ '传入的报告' if nr.source == 'argument' else '本机缓存' }}{{ nr.run_id }}{% if nr.generated %} · {{ nr.generated }}{% endif %}
新失败
{{ newly_run|length }}
仍在失败
{{ nr.still_failing|length }}
恢复通过
{{ nr.recovered|length }}
{%- if newly_run or nr.recovered %} {%- endif %} {%- if nr.selector != selector or nr.env != env %}
注意:那次运行的选择器或环境和这次不同({{ nr.selector or '全部' }} · {{ nr.env or 'default' }})。
{% endif %}
{%- endif %}
{%- endif %} {%- if merge_conflicts %}

分片之间结论不一致

{{ merge_conflicts|length }} 条 · 合并后取更严重的结论;可能是不稳定的用例,也可能是各分片跑的代码不同
{%- endif %} {%- if attention %}

需要看的用例 · 按归因分组

归因由规则判断,不经过 LLM;每条都写了依据
{%- for g in attention %}
{{ g.label }} · {{ g.cases|length }} {%- if g.next_step %}{{ '下一步:' if g.key != 'untrusted' else '' }}{{ g.next_step }}{% endif %}
{% if baseline or nr %}{% endif %} {%- for c in g.cases %} {%- set bad_checks = c.judgements|rejectattr('ok')|list %} {%- if baseline or nr %} {%- endif %} {%- endfor %}
用例{{ '情况' if g.key == 'untrusted' else '不成立的判定' }}依据对比
{{ c.title or c.name }}{% if c.title %}
{{ c.name }}
{% endif %}
{%- if g.key == 'untrusted' %} {%- if c.status == 'UNVERIFIED' %}
未判定:通过了,没有判定任何东西
{% endif %} {%- if c.flaky %}
标记为不稳定:{{ c.flaky }}
{% endif %} {%- if weak_by_case.get(c.nodeid) %}
比基线弱 {{ weak_by_case[c.nodeid] }} 处
{% endif %} {%- if c.gaps %}
声明了 {{ c.gaps|length }} 处缺口
{% endif %} {%- elif bad_checks %} {%- for k in bad_checks[:3] %}
{{ k.expr or k.name }}
{% endfor %} {%- if bad_checks|length > 3 %}
另有 {{ bad_checks|length - 3 }} 条
{% endif %} {%- else %}
{{ (c.message or '—').splitlines()[0] }}
{% endif %}
{{ c.reason or '—' }}{% if c.confidence %}
{{ confidence_label.get(c.confidence, c.confidence) }}
{% endif %}
{%- if c.nodeid in newly_base %}新红 {%- elif c.nodeid in nb.get('no_passing_record', []) %}基线无通过记录{% endif %} {%- if c.nodeid in newly_run %} 比上次新失败{% endif %}
{%- endfor %}
{%- endif %} {%- if judgement_changes %}

判定变化

{{ weakened }} 处变弱 · 共 {{ judgement_changes|length }} 处 · 对比 {{ baseline_display }}

比的是这次实际执行的判定,不是源码文本。失败的用例中途停止,不参与比较,通过后再比。 {%- if weakened %} 变弱的那几处要有人确认:有意的就用 ipandora run --update-baseline 写回基线,改动会出现在 PR 的 diff 里;不是有意的,把判定加回来。{% endif %}

{%- for c in judgement_changes %}
{{ c.label }} {{ c.case }} {% if c.before and c.after %}{{ c.before }} → {{ c.after }}{% else %}{{ c.before or c.after }}{% endif %} {%- if c.note %}{{ c.note }}{% endif %}
{%- endfor %}
{%- endif %} {%- if coverage or by_source %}

判定构成与覆盖

{%- for grid in coverage %}

{{ grid.title or '覆盖' }}{% if not grid.problem %} · {{ grid.filled }} / {{ grid.total }}{% endif %}

{%- if grid.note %}

{{ grid.note }}

{% endif %} {%- if grid.problem %}
{{ grid.problem }}
{%- else %}
{% for x in grid.x_labels %}{% endfor %} {%- for row in grid.rows %} {%- for cell in row.cells %} {%- endfor %} {%- endfor %}
{{ grid.y_label }}{{ x }}
{{ row.label }}{% if cell.cases %}{{ cell.cases|length }} 条{% else %}无用例{% endif %}
{%- endif %}
{%- endfor %} {%- if by_source %}

判定按来源

数的是判定,不是用例。一个套件可以每条用例都通过,却几乎什么都没判。

{%- for src, n in by_source.items() %} {%- set gp = n.get('gap', 0) %}
{{ src }} {{ n.passed }} 成立{% if n.failed %} · {{ n.failed }} 不成立{% endif %}{% if gp %} · {{ gp }} 缺口{% endif %}
{%- endfor %}
{%- endif %}
{%- endif %}

用例明细

{%- for suite, suite_cases in by_suite.items() %}
{{ suite }}
{%- for case in suite_cases %} {%- set bad = case.status in ('FAIL', 'ERROR') %}
{{ status_label.get(case.status, case.status) }} {{ case.title or case.name }}{{ case.nodeid or case.name }} {%- for key, value in case.dims.items() %}{{ key }}={{ value }}{% endfor %} {%- if case.judgements %}{{ case.checks_passed }} / {{ case.judgements|length }} 成立{% endif %} {{ '%.3f'|format(case.duration) }} s
{%- if case.reason %}
{{ category_label.get(case.category, case.category or status_label.get(case.status, case.status)) }}{% if case.confidence %} · {{ confidence_label.get(case.confidence, case.confidence) }}{% endif %} {{ case.reason }} {%- if case.next_step %}下一步:{{ case.next_step }}{% endif %}
{%- elif case.status == 'UNVERIFIED' %}
未判定{{ case.message }}下一步:补上判定;有意不判的,用 gap() 声明出来
{%- endif %} {%- if case.checks %}

判定 · {{ case.checks_failed }} / {{ case.judgements|length }} 不成立{% if case.gaps %} · {{ case.gaps|length }} 处缺口{% endif %}

{%- for check in case.checks|sort(attribute='ok') %} {%- set cls = 'gap' if check.kind == 'gap' else ('ok' if check.ok else 'bad') %} {%- endfor %}
结果判定实际来源
{{ '缺口' if cls == 'gap' else ('成立' if check.ok else '不成立') }} {{ check.name }} {{ check.expr or '—' }} {{ check.src }}
{%- elif case.status == 'PASS' %}
这条用例通过了,但没有留下任何判定。
{%- endif %} {%- if case.faults %}
这条用例注入了故障:{% for f in case.faults %}{{ f.description }}{% if f.started and f.ended %}({{ '%.1f'|format(f.ended - f.started) }} 秒){% endif %}{% if not loop.last %};{% endif %}{% endfor %}。
{%- endif %} {%- set visual = case.artifacts|selectattr('kind', 'in', ['golden', 'actual', 'diff'])|list %} {%- for group in visual|map(attribute='group')|unique %}

图像判定 · {{ group }}

{%- for kind, ttl in [('golden', '基准图'), ('actual', '本次'), ('diff', '差异(红框为变化位置)')] %} {%- for a in visual if a.group == group and a.kind == kind %}
{{ ttl }}
{% if a.src %}{{ ttl }}{% else %}{{ a.path }}{% endif %}
{%- endfor %} {%- endfor %}
{%- endfor %} {%- set histories = case.artifacts|selectattr('kind', 'in', ['history', 'checker-result'])|list %} {%- if histories %}

操作历史

{%- for a in histories %} {%- if a.kind == 'history' %}
{{ a.group }} 模型的历史(EDN),复查{{ a.command }}
{%- else %}
检查器的完整结论{{ a.path }}
{% endif %} {%- endfor %}
{%- endif %} {%- set others = case.artifacts|rejectattr('kind', 'in', ['golden', 'actual', 'diff', 'history', 'checker-result'])|list %} {%- if others %}

失败现场

{%- for a in others %} {%- if a.kind == 'screenshot' and a.src %}失败时的页面截图 {%- elif a.kind == 'screenshot' %}
截图{{ a.path }}
{%- elif a.kind == 'trace' %}
回放(逐步重现失败过程){{ a.command }}
{%- elif a.kind == 'device-log' %}
设备 {{ a.group }} 用例期间的 logcat(App 自己的日志、崩溃和 ANR) {{ a.path }}
{{ a.text }}
{%- elif a.kind == 'hierarchy' %}
失败时界面的控件树{{ a.path }}
{%- elif a.kind == 'service-log' %}
服务 {{ a.group }} 用例期间的日志 {{ a.path }}
{{ a.text }}
{%- else %}
{{ a.kind }}{{ a.path }}
{% endif %} {%- endfor %}
{%- endif %} {%- set traces = case.traces %} {%- if traces %}
链路(服务端的调用记录):{% for t in traces %}{% if t.url|web_url %}{{ t.id }}{% else %}{{ t.id }}{% endif %}{% if not loop.last %}、{% endif %}{% endfor %}
{%- endif %} {%- if case.exchanges %} {%- set total_ms = case.exchange_ms or 1 %}
{%- set kinds = case.exchanges|map(attribute='protocol')|map('default', '')|unique|list %}

{% for k in kinds %}{{ protocol_names.get(k, k) }}{{ ' / ' if not loop.last }}{% endfor %} · {{ case.exchanges|length }} 次请求 · {{ case.exchange_ms|ms }}

{%- for ex in case.exchanges %} {%- set grpc = ex.protocol == 'grpc' %} {%- set kafka = ex.protocol == 'kafka' %} {%- set other = ex.protocol in ok_reasons %} {#- no reason: not ended when the case did -- a stream the test stopped reading, a message not yet acknowledged #} {%- set broke = ex.error or ((ex.reason and ex.reason not in ok_reasons[ex.protocol]) if other else not (ex.status and 200 <= ex.status < 400)) %}
{{ ex.method or 'GET' }} {{ ex.url }} {%- if ex.identity %}{{ ex.identity }}{% endif %} {%- if ex.role == 'login' %}登录{% elif ex.role == 'replayed' %}未登录被拒,重新登录后重放{% endif %} {{ (ex.reason or '未结束') if other else (ex.status or '—') }} {{ ex.ms|ms }}
{%- if ex.error %}
{{ ex.error }}
{% endif %}
{{ ex.method or 'GET' }}{{ ex.url }} {%- if other %}{{ ex.reason or '未结束' }} {%- else %} {%- if ex.status %}{{ ex.status }} {{ ex.reason }}{% endif %} {%- endif %}
{%- if ex.trace_id %}
链路 {% if ex.trace_url|web_url %}{{ ex.trace_id }}{% else %}{{ ex.trace_id }}{% endif %}
{% endif %}
{{ '请求元数据' if grpc else ('消息头' if kafka else '请求头') }}
{% if ex.request_headers %}
{% for k, v in ex.request_headers.items() %}{{ k }}: {{ v }}
{% endfor %}
{% else %}
无
{% endif %}
{{ '尾部元数据' if grpc else ('分区与位移' if kafka else '响应头') }}
{% if ex.response_headers %}
{% for k, v in ex.response_headers.items() %}{{ k }}: {{ v }}
{% endfor %}
{% else %}
无
{% endif %}
{%- if ex.request_body %}
{{ '请求消息' if grpc else ('消息' if kafka else '请求体') }}
{{ ex.request_body|hljson }}
{% endif %}
{{ '响应消息' if grpc else ('消息' if kafka else '响应体') }}
{% if ex.response_body %}
{{ ex.response_body|hljson }}
{% else %}
无响应体
{% endif %}
{%- endfor %} {%- set slow = case.slowest_exchange %} {%- if slow and case.exchanges|length > 1 and (slow.ms or 0) > 0.4 * total_ms %}
最慢一次占 {{ '%.0f'|format(100.0 * slow.ms / total_ms) }}%({{ slow.ms|ms }}),其余 {{ (total_ms - slow.ms)|ms }}。
{%- endif %}
{%- endif %} {%- if bad and case.message %}
失败原文
{{ case.message }}
{%- endif %}
{%- endfor %}
{%- endfor %}
{%- if gap_list %}

断言缺口汇总

{{ gap_list|length }} 类,共 {{ check_totals.gap }} 处

由各用例声明的 gap() 聚合而来,用来排下一轮补测的优先级。缺口是关于套件的陈述,不影响本次结论。

{%- for g in gap_list %}
{{ g.name }}{% if g.expr %}{{ g.expr }}{% endif %}{{ g.count }} 处 · {{ g.src }}
{%- endfor %}
{%- endif %}