Files
dingtalk-workspace-cli/scripts/shortcut_real_result_test.py
DennisandClaude Opus 4.8 b1e7b43f85 fix(shortcut): fix projection-data-loss silent-empty returns
Several read shortcuts returned an empty list with exit 0 and no error
envelope even though the underlying MCP tool returned data, so agents misread
"no data" and made wrong decisions.

Root causes:
- Container key mismatch: the resolver probed the wrong key —
  processCodeList / values / wikiSpaces / itemList / groupList / recentItems /
  emailAccounts / deptUserList / labelUserList / roles / report_list, plus
  get_org_labels grouped labels[] needing a descend.
- Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO /
  userInfo.
- Param exceeded a backend limit: todo +created-todos sent pageSize=50 while
  the backend silently returns empty for pageSize>20; now uses the shared pager
  (pageSize=20).

Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart
resolvers. Every fix ships a guard test that feeds the real backend response
shape (and, for minutes, both the taskUuid and minutesId item shapes) and
asserts the projection is non-empty with a usable id.

scripts/shortcut_real_result.py now compares the upper (projection) output
against the lower (raw backend) layer, and record_real_shortcut_run.py captures
the lower layer in memory (persisting only derived counts, never raw PII) so an
exit-0 empty projection over a non-empty backend is scored as
projection-data-loss instead of real-ok. The Python self-test runs in CI via
test/scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:07:02 +08:00

205 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Self-checking tests for scripts/shortcut_real_result.py.
Run: python3 scripts/shortcut_real_result_test.py
Focus: the upper/lower layer comparison rules added after the
contact +list-roles / oa +list-forms projection-data-loss case, plus a
regression guard that pre-existing exit-code / error-envelope behaviour is
unchanged.
"""
from __future__ import annotations
import json
from shortcut_real_result import (
backend_record_count,
classify_failure,
classify_real_status,
compare_layers,
count_projection_items,
payload_indicates_error,
projection_audit,
)
# Faithful backend shapes from the real case.
GROUPED_ROLES = {
"result": [
{"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]},
{"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]},
]
}
PROCESS_CODE_LIST = {
"result": {
"processCodeList": [
{"processCode": "PROC-1", "processName": "请假"},
{"processCode": "PROC-2", "processName": "加班"},
],
"totalCount": -1,
},
"success": True,
}
EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []})
FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]})
DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"})
def check(name, cond):
if not cond:
raise AssertionError(f"FAIL: {name}")
print(f" ok: {name}")
def test_count_projection_items():
check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0)
check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2)
check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None)
check("bare array -> len", count_projection_items("[1,2,3]") == 3)
check("empty string -> None", count_projection_items("") is None)
def test_backend_record_count():
# backend_record_count is a "has data" proxy = size of the largest single
# object array found at any depth (not the flattened total).
check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2)
check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2)
check("empty backend -> 0", backend_record_count({"result": []}) == 0)
check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0)
def test_compare_layers():
lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES)
check("roles: data loss detected", lost and upper == 0 and lower == 2)
lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES)
check("roles: no loss when upper populated", not lost2)
lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []})
check("roles: no loss when backend also empty", not lost3 and lower3 == 0)
def test_classify_real_status():
# Backward-compat: without backend_raw, empty projection is still real-ok.
check(
"legacy empty projection stays real-ok",
classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok",
)
# With lower layer that has data, empty projection becomes real-error.
check(
"empty projection + backend data -> real-error",
classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error",
)
# Populated projection stays ok even with backend.
check(
"full projection + backend -> real-ok",
classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok",
)
# Regression: non-zero exit and error envelope untouched.
check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error")
check(
"error envelope -> real-error",
classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error",
)
check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout")
def test_classify_failure_projection_loss():
result = {
"status": "real-ok", # process exited 0, looked fine
"exit_code": 0,
"service": "contact",
"command": "+list-roles",
"stdout": EMPTY_ROLES_PROJECTION,
"backend_stdout": json.dumps(GROUPED_ROLES),
}
category, fixability, _ = classify_failure(result)
check("projection-data-loss category", category == "projection-data-loss")
check("cli fixable", fixability == "cli-projection-fix-needed")
# No loss when projection is populated.
ok_result = dict(result, stdout=FULL_ROLES_PROJECTION)
check("populated -> passed", classify_failure(ok_result)[0] == "passed")
# Regression: a normal real-ok with no backend capture still passes.
check(
"plain real-ok passes",
classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed",
)
def test_projection_audit():
# Empty projection, no backend -> must-verify warning.
warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION})
check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified")
# Empty projection + backend data -> data loss.
warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES})
check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss")
# Populated projection -> no warning.
check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None)
# Detail (non-list) projection -> no warning.
check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None)
def test_payload_indicates_error_regression():
check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION))
check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1})))
check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0})))
def test_record_run_wires_lower_layer():
"""End-to-end: record_real_shortcut_run.py must capture the lower layer and
flag an exit-0 empty projection as projection-data-loss (not real-ok)."""
import os
import subprocess
import tempfile
here = os.path.dirname(os.path.abspath(__file__))
recorder = os.path.join(here, "record_real_shortcut_run.py")
with tempfile.TemporaryDirectory() as d:
backend = os.path.join(d, "backend.json")
out = os.path.join(d, "out.json")
# Lower layer has 3 roles nested under result[].labels[].
with open(backend, "w") as f:
json.dump(GROUPED_ROLES, f)
env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out)
# Upper command: an empty projection (exit 0, no error envelope).
argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"]
p = subprocess.run(
["python3", recorder, "--service", "contact", "--command", "+list-roles",
"--risk", "read", "--backend-raw", backend, "--"] + argv,
capture_output=True, text=True, env=env,
)
check("recorder exits non-zero on data loss", p.returncode == 2)
rec = json.loads(p.stdout)
check("status flipped to real-error", rec["status"] == "real-error")
check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss")
check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss")
# Raw backend payload must NOT be persisted.
persisted = json.load(open(out))
blob = json.dumps(persisted, ensure_ascii=False)
check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob)
def main():
tests = [
test_count_projection_items,
test_backend_record_count,
test_compare_layers,
test_classify_real_status,
test_classify_failure_projection_loss,
test_projection_audit,
test_payload_indicates_error_regression,
test_record_run_wires_lower_layer,
]
for t in tests:
print(f"{t.__name__}:")
t()
print(f"\nAll {len(tests)} test groups passed.")
if __name__ == "__main__":
main()