Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025287873d | ||
|
|
6ddda6f1bf | ||
|
|
dde5049454 | ||
|
|
5d7a66d4a3 | ||
|
|
aebb75371b | ||
|
|
0a0634cfc2 | ||
|
|
46aa0fe16d | ||
|
|
78165393e0 | ||
|
|
f6a4e0d5ad | ||
|
|
fb44601f21 | ||
|
|
83c64d31dd | ||
|
|
e437cf4bbb | ||
|
|
495a3b256f | ||
|
|
4210334f55 | ||
|
|
30caba5dcb | ||
|
|
76316ef5f0 | ||
|
|
f5b1c2659f | ||
|
|
b4f0053bbe | ||
|
|
3593818a46 | ||
|
|
21bbf42ca7 | ||
|
|
edf1e58141 | ||
|
|
bd94c63de8 | ||
|
|
9d8806927f | ||
|
|
8c2c94e0f1 | ||
|
|
b7b78f0c16 | ||
|
|
5ce391b49b | ||
|
|
4a14f4b1e3 | ||
|
|
451a6fffe7 | ||
|
|
e4e653d3b3 | ||
|
|
6b85867309 | ||
|
|
a5902ca233 | ||
|
|
28669ffeee | ||
|
|
fa5bc65d66 | ||
|
|
49afa82d27 | ||
|
|
3eda3b5ce6 | ||
|
|
49ab7a46f4 | ||
|
|
d500f2fe5f | ||
|
|
bcc9e27da0 | ||
|
|
cf64f2ad02 | ||
|
|
5ab46921c5 | ||
|
|
f8a031564a | ||
|
|
2989c1db37 | ||
|
|
eaee7f1c6f | ||
|
|
103b188458 | ||
|
|
156d95e6d1 | ||
|
|
b1f5c67e9c | ||
|
|
037deefe67 | ||
|
|
06ed3aeeb3 | ||
|
|
b244df1634 | ||
|
|
eb3f7328bb | ||
|
|
3c445ce73a | ||
|
|
ef73257a69 | ||
|
|
7945f44c9a | ||
|
|
8034f0c2dc | ||
|
|
5b0e44290e | ||
|
|
6fdd17d3b6 | ||
|
|
5c2181a31d | ||
|
|
956819663d | ||
|
|
670ab1fd5e | ||
|
|
b299400017 | ||
|
|
3afcabc41d | ||
|
|
a43e75e8df | ||
|
|
2aad96fa7b | ||
|
|
3fe2a7f5c0 | ||
|
|
24fd2d2573 | ||
|
|
90d99d9bbe | ||
|
|
a37f614be4 | ||
|
|
08cf334cc1 | ||
|
|
12088f2d44 | ||
|
|
d9c74fbe96 | ||
|
|
1522653844 | ||
|
|
357f31376d | ||
|
|
0f178f8382 | ||
|
|
910fb4a9b1 | ||
|
|
89feea7971 | ||
|
|
24b61b1c17 | ||
|
|
edbc8275b6 | ||
|
|
27afa806ca | ||
|
|
b6101bdbc3 | ||
|
|
538f2aba6f | ||
|
|
2359de69fa | ||
|
|
b62f6c0c02 | ||
|
|
25b5e0b9fa | ||
|
|
03bda02e04 | ||
|
|
4da1e52b08 | ||
|
|
409ee0cb84 | ||
|
|
7cf7598ef2 | ||
|
|
9b220d0ee6 | ||
|
|
d7ae59753d | ||
|
|
72b2af1d1d | ||
|
|
bd41da8caf | ||
|
|
cc0e179a8d | ||
|
|
e0f66384e2 | ||
|
|
2dd067562e |
@@ -1,14 +1,17 @@
|
||||
name: PR Eval Dispatch
|
||||
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
|
||||
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
|
||||
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
|
||||
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
|
||||
# FETCH_HEAD 校验兜底派发后的变更。
|
||||
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
|
||||
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
|
||||
# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。
|
||||
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
|
||||
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
|
||||
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
|
||||
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -91,49 +94,217 @@ jobs:
|
||||
| python3 scripts/ci/eval_dispatch_guard.py head \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Trigger internal evaluation pipeline
|
||||
env:
|
||||
EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }}
|
||||
EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then
|
||||
echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
jq -n \
|
||||
--arg pr "$PR_NUMBER" \
|
||||
--arg sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases "$CASES_REF" \
|
||||
'{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "private-token: ${EVAL_TRIGGER_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- \
|
||||
"$EVAL_TRIGGER_URL"
|
||||
echo "Internal evaluation dispatched."
|
||||
|
||||
- name: Acknowledge on PR
|
||||
- name: Create dispatch placeholder
|
||||
id: placeholder
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
jq -n --arg body "$placeholder_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
| jq -er \
|
||||
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
|
||||
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
|
||||
)"
|
||||
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dispatch request manifest
|
||||
env:
|
||||
REPOSITORY_ID: '1187709537'
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
ACTOR_ID: ${{ github.event.comment.user.id }}
|
||||
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
SOURCE_BODY: ${{ github.event.comment.body }}
|
||||
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
|
||||
echo "unexpected repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for value in \
|
||||
"$REPOSITORY_ID" \
|
||||
"$WORKFLOW_ID" \
|
||||
"$RUN_ID" \
|
||||
"$RUN_ATTEMPT" \
|
||||
"$SOURCE_COMMENT_ID" \
|
||||
"$DISPATCH_COMMENT_ID" \
|
||||
"$ACTOR_ID" \
|
||||
"$PR_NUMBER"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "dispatch manifest contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "dispatch manifest contains an invalid PR head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
|
||||
source_body_sha256="${hash_output%% *}"
|
||||
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "failed to hash source comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
|
||||
|
||||
umask 077
|
||||
jq -n \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg repository "$REPOSITORY" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg source_comment_id "$SOURCE_COMMENT_ID" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg actor_id "$ACTOR_ID" \
|
||||
--arg actor_login "$ACTOR_LOGIN" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg pr_head_sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases_ref "$CASES_REF" \
|
||||
--arg source_body_sha256 "$source_body_sha256" \
|
||||
--arg idempotency_key "$idempotency_key" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
repository: $repository,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
source_comment_id: $source_comment_id,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
actor_id: $actor_id,
|
||||
actor_login: $actor_login,
|
||||
pr_number: $pr_number,
|
||||
pr_head_sha: $pr_head_sha,
|
||||
products: $products,
|
||||
cases_ref: $cases_ref,
|
||||
source_body_sha256: $source_body_sha256,
|
||||
idempotency_key: $idempotency_key
|
||||
}' > "$MANIFEST_PATH"
|
||||
|
||||
- name: Upload dispatch request manifest
|
||||
id: artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
|
||||
path: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: false
|
||||
|
||||
- name: Finalize dispatch marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
REPOSITORY_ID: '1187709537'
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
|
||||
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
|
||||
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "artifact marker contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact_digest="${ARTIFACT_DIGEST,,}"
|
||||
if [[ "$artifact_digest" != sha256:* ]]; then
|
||||
artifact_digest="sha256:${artifact_digest}"
|
||||
fi
|
||||
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "artifact marker contains an invalid digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
marker_json="$(
|
||||
jq -nc \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg artifact_id "$ARTIFACT_ID" \
|
||||
--arg artifact_digest "$artifact_digest" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
artifact_id: $artifact_id,
|
||||
artifact_digest: $artifact_digest
|
||||
}'
|
||||
)"
|
||||
cases_note=""
|
||||
if [ -n "$CASES_REF" ]; then
|
||||
cases_note=",用例版本 \`${CASES_REF}\`"
|
||||
fi
|
||||
body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
jq -n --arg body "$body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
--arg comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg body "$body" \
|
||||
'((.id | tostring) == $comment_id) and (.body == $body)' \
|
||||
> /dev/null
|
||||
|
||||
- name: Mark dispatch preparation failure
|
||||
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
jq -n --arg body "$failure_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
@@ -464,6 +464,134 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Unified result Schema and performance
|
||||
|
||||
The unified runtime envelope and the per-command Schema result declaration are
|
||||
related but distinct contracts:
|
||||
|
||||
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
|
||||
`meta`) and derives it through `internal/output`. Business commands return a
|
||||
`CommandResult`; they must not hand-author the outer JSON shape.
|
||||
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
|
||||
business value inside `data`. It may declare `outcomes`, `data_schema`, and
|
||||
`sensitive_paths`. `Contract.Pagination` is a separate command capability
|
||||
because pagination is emitted under envelope `meta`, not inside `data`.
|
||||
- `outcomes` is the set of results a command may produce; it is not the outcome
|
||||
of the current invocation. `data_schema` is a JSON Schema object for business
|
||||
data and must not duplicate the framework envelope.
|
||||
- Result declarations are delivered in the full leaf and in the reviewed
|
||||
`--compact` Agent projection. Compact retains the normalized `result` object
|
||||
verbatim but still omits provenance, interface bindings, and other audit-only
|
||||
fields. Product/group summaries remain navigation views and need not repeat
|
||||
every leaf Result. When an Agent needs return-shape facts, query the compact
|
||||
leaf directly; do not load the whole full Catalog.
|
||||
- A missing `result` means “no reviewed return-value declaration is published
|
||||
for this leaf.” It does **not** prove that the runtime is legacy, and it must
|
||||
not be filled by inference from examples, MCP samples, or previous command
|
||||
output. Runtime rollout remains an internal per-command fact.
|
||||
- The public contract has no `contract_version`, no `--output-contract`, and no
|
||||
Agent-selectable protocol alias. Agents continue to request machine output
|
||||
with `--format json`; migrated commands use the unified result directly and
|
||||
unmigrated commands retain their current legacy output.
|
||||
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
|
||||
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
|
||||
currently has representative Result coverage. Do not describe that as
|
||||
repository-wide coverage. Any newly activated Agent-visible command should
|
||||
add and test its Result declaration; the remaining pilot gaps should shrink,
|
||||
not expand.
|
||||
|
||||
The compact/full leaf `result` object has one stable shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "pending", "partial_failure", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Stable resource ID"},
|
||||
"name": {"type": "string", "description": "Display name"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensitive_paths": ["credential.secret"]
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
| Field | Required | Contract |
|
||||
|---|---|---|
|
||||
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
|
||||
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
|
||||
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
|
||||
|
||||
Optional members are omitted, never emitted as `null`. A leaf without a
|
||||
reviewed Result omits the entire `result` key. Compact must preserve the same
|
||||
normalized Result value as the full leaf; it must not summarize, infer, rename,
|
||||
or independently rebuild any Result field. Product/group summaries do not
|
||||
aggregate child Result objects.
|
||||
|
||||
`pagination` is a sibling of `result`, not a child. It declares the canonical
|
||||
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
|
||||
Product response fields used to derive that metadata remain mapper internals;
|
||||
they are not part of `result.data_schema`. Do not execute a second request to
|
||||
derive pagination metadata.
|
||||
|
||||
Invalid result declarations fail closed during normalization: unknown or
|
||||
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
|
||||
sensitive paths, unsupported pagination kinds, attempts to override framework
|
||||
meta paths, and an invalid cursor parameter must be rejected rather than
|
||||
silently removed.
|
||||
Full-leaf wire round trips must
|
||||
preserve the normalized Result exactly. Do not commit generated Schema JSON as
|
||||
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
|
||||
from declarations.
|
||||
|
||||
### Performance model and rules
|
||||
|
||||
- Catalog construction is declaration-driven and cached through the existing
|
||||
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
|
||||
command invocation, per leaf lookup, or per renderer.
|
||||
- Normalizing one Result declaration is linear in the size of that declaration.
|
||||
Full `schema --all` is linear in tools + parameters + Result schema bytes and
|
||||
is an audit/compatibility export, not the normal Agent discovery path.
|
||||
Overview → compact product/group → compact leaf remains the normal route;
|
||||
only the final leaf carries its Result declaration.
|
||||
- Constructing a `CommandResult` defensively clones result data and validates
|
||||
invariants; rendering is buffer-first and then writes once. Both CPU cost and
|
||||
transient memory are O(payload size), with roughly one additional in-memory
|
||||
rendered copy. This buys immutability and prevents partial JSON leakage, but
|
||||
it is not free.
|
||||
- Large list/search commands must use bounded pages and publish continuation
|
||||
facts. The current emitter buffers one command result/page before publishing;
|
||||
pagination is the memory bound. Continuous event streams are a separate,
|
||||
command-specific protocol and are not described by `ResultSpec`.
|
||||
- A `dual_validate` command must execute the business request exactly once,
|
||||
validate a shadow unified result, and preserve legacy bytes. Never obtain
|
||||
validation by issuing a second network or write request.
|
||||
- Filters and alternate formats are render-time work over the same in-memory
|
||||
result. They must not rerun the business operation or rebuild Schema.
|
||||
- Performance changes must preserve the one-result, buffer-first, fail-closed,
|
||||
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
|
||||
improvement. For a material hot-path change, benchmark representative small
|
||||
and page-sized payloads and report allocations/bytes as well as latency.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
@@ -479,8 +607,9 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
constraints, safety/confirmation semantics, and any reviewed `result`
|
||||
contract. Full leaf fields such as `property`, `interface_ref`, and
|
||||
provenance are audit facts. A conflict is contract drift, not permission to
|
||||
guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.2"
|
||||
version "1.0.58-beta.3"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
|
||||
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# 独立 `meta.pagination` Schema 方案
|
||||
|
||||
## 1. 目标结构
|
||||
|
||||
业务结果与分页控制信息分层:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {
|
||||
"items": [{"id": "a"}]
|
||||
},
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"endpoint_exhausted": false,
|
||||
"next_token": "cursor-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应 compact/full leaf Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "当前页业务记录",
|
||||
"items": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
|
||||
|
||||
## 2. 分页状态
|
||||
|
||||
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|
||||
|---|---:|---|---|
|
||||
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
|
||||
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
|
||||
|
||||
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
|
||||
健康、数据全量覆盖或业务对象不存在。
|
||||
|
||||
## 3. 映射规则
|
||||
|
||||
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
|
||||
等字段,但统一 CLI 输出只公布 `meta.pagination`:
|
||||
|
||||
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
|
||||
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
|
||||
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
|
||||
`pagination_inconsistent`,禁止伪装终页。
|
||||
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
|
||||
|
||||
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
|
||||
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
|
||||
|
||||
## 4. Schema 规则
|
||||
|
||||
- `kind` 当前只允许 `cursor`。
|
||||
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
|
||||
同一 leaf 的 `parameters`。
|
||||
- 三个 meta path 由框架固定生成,产品不能覆盖。
|
||||
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
|
||||
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
|
||||
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
|
||||
|
||||
## 5. 渐进接入
|
||||
|
||||
1. **legacy_only**:保持原输出,不公布分页声明。
|
||||
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
|
||||
legacy 字节不变。
|
||||
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
|
||||
`pagination` 声明。
|
||||
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
|
||||
|
||||
不增加 `contract_version`、`--output-contract` 或分页协议别名。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
每个分页命令至少验证:
|
||||
|
||||
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
|
||||
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
|
||||
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
|
||||
4. `cursor_parameter` 在 Help/Schema 中真实存在。
|
||||
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
|
||||
6. `data_schema` 不包含分页控制字段。
|
||||
7. 运行时 `data` 不包含迁移后的分页控制字段。
|
||||
8. dual validate 与 active 都只消费一次上游响应。
|
||||
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
|
||||
|
||||
### DevApp 首批落地
|
||||
|
||||
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
|
||||
`meta.pagination` 返回分页控制信息:
|
||||
|
||||
- `dev app list`
|
||||
- `dev app permission list`
|
||||
- `dev app event list`
|
||||
- `dev app version list`
|
||||
- `devapp +list`
|
||||
- `devapp +permission-list`
|
||||
- `devapp +event-list`
|
||||
- `devapp +version-list`
|
||||
|
||||
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
|
||||
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
|
||||
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
|
||||
`hasMore/nextCursor`。
|
||||
|
||||
## 7. 对齐依据
|
||||
|
||||
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
|
||||
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
|
||||
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
|
||||
@@ -0,0 +1,108 @@
|
||||
# DWS 统一命令框架设计概要
|
||||
|
||||
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
|
||||
|
||||
## 1. 产品裁决
|
||||
|
||||
1. 不公开 `--output-contract`,也不增加任何等价别名。
|
||||
2. Agent 继续只使用既有 `--format json`。
|
||||
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
|
||||
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
|
||||
5. 回滚是命令声明与发布行为,不改变消费者 argv。
|
||||
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
|
||||
|
||||
## 2. 渐进迁移
|
||||
|
||||
内部状态机:
|
||||
|
||||
```text
|
||||
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
|
||||
```
|
||||
|
||||
- `legacy_only`:只构造、输出 legacy。
|
||||
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
|
||||
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
|
||||
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
|
||||
- `unified_only`:清理仅服务 legacy 的产品 renderer。
|
||||
|
||||
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
|
||||
|
||||
## 3. 统一结果
|
||||
|
||||
统一命令框架表达四类结果:
|
||||
|
||||
```text
|
||||
success 请求完成且命令认为操作已完成
|
||||
pending 请求被受理,但异步操作尚未终结
|
||||
partial_failure 批量操作有成功项,也有失败或未知项
|
||||
failure 请求或操作失败
|
||||
```
|
||||
|
||||
JSON 基本形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
硬不变量:
|
||||
|
||||
```text
|
||||
ok == (outcome in {success, pending})
|
||||
process rc == 0 <=> ok == true
|
||||
top-level error present <=> outcome == failure
|
||||
one invocation emits exactly one primary result
|
||||
```
|
||||
|
||||
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
|
||||
|
||||
## 4. 输出与错误纪律
|
||||
|
||||
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
|
||||
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
|
||||
自己声明专用流协议。
|
||||
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
|
||||
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
|
||||
混入分页控制字段。
|
||||
- 日志不得污染 stdout。
|
||||
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
|
||||
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
|
||||
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
|
||||
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
|
||||
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
|
||||
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
|
||||
|
||||
## 5. 重试与超时边界
|
||||
|
||||
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
|
||||
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
|
||||
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
|
||||
|
||||
## 6. 集成范围
|
||||
|
||||
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
|
||||
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
|
||||
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
|
||||
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
|
||||
|
||||
## 7. 对齐原则
|
||||
|
||||
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
|
||||
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
|
||||
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
|
||||
|
||||
## 8. 发布门禁
|
||||
|
||||
命令晋级 `unified_active` 前至少满足:
|
||||
|
||||
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
|
||||
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
|
||||
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
|
||||
4. Help、Schema 和全仓示例不存在协议选择参数。
|
||||
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
|
||||
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
|
||||
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
|
||||
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
|
||||
@@ -0,0 +1,525 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
|
||||
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
|
||||
retry := int64(4)
|
||||
started := true
|
||||
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
|
||||
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
|
||||
typed := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
|
||||
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
|
||||
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
|
||||
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
|
||||
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
|
||||
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
|
||||
Cause: call,
|
||||
}
|
||||
info := errorInfoFromExecutionError(typed)
|
||||
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
|
||||
t.Fatalf("projection=%+v", info)
|
||||
}
|
||||
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
|
||||
t.Fatalf("recovery metadata=%+v", info)
|
||||
}
|
||||
|
||||
innerOperation := &helpers.CLIError{Operation: "create"}
|
||||
outerWithoutOperation := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI,
|
||||
Message: "failed",
|
||||
Cause: innerOperation,
|
||||
}
|
||||
preserved := errorInfoFromExecutionError(outerWithoutOperation)
|
||||
if preserved.Operation != "create" {
|
||||
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
|
||||
}
|
||||
|
||||
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
|
||||
requestInfo := errorInfoFromExecutionError(requestCall)
|
||||
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
|
||||
t.Fatalf("request projection=%+v", requestInfo)
|
||||
}
|
||||
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
|
||||
if partial.Type != "internal" {
|
||||
t.Fatalf("partial error type=%s", partial.Type)
|
||||
}
|
||||
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
|
||||
if got := errorTypeForExitCode(code); got != want {
|
||||
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "leaf"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
leaf.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
leaf.SetOut(&stdout)
|
||||
}
|
||||
leaf.SetErr(&bytes.Buffer{})
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"active-no-result"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("ExecuteC error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
|
||||
originalRemove := rootRemoveFile
|
||||
t.Cleanup(func() { rootRemoveFile = originalRemove })
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootRemoveFile = func(string) error { return errors.New("remove failed") }
|
||||
cmd := &cobra.Command{Use: "abort"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
|
||||
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
|
||||
t.Fatalf("abort error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
|
||||
installOutputSinkRunBoundary(nil)
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
plain.SetContext(context.Background())
|
||||
installOutputSinkRunBoundary(plain)
|
||||
|
||||
// newBoundaryChild builds a leaf whose --output lives on the root's
|
||||
// persistent flag set, matching production wiring (a local --output flag
|
||||
// belongs to the leaf's own business contract and skips the sink).
|
||||
newBoundaryChild := func(outputPath string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().String("output", outputPath, "")
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
root.AddCommand(cmd)
|
||||
cmd.SetContext(context.Background())
|
||||
return cmd
|
||||
}
|
||||
|
||||
var calls int
|
||||
cmd := newBoundaryChild("")
|
||||
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PostRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runOnly := newBoundaryChild("")
|
||||
runOnly.Run = func(*cobra.Command, []string) { calls++ }
|
||||
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
|
||||
installOutputSinkRunBoundary(runOnly)
|
||||
if runOnly.Run != nil || runOnly.RunE == nil {
|
||||
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
|
||||
}
|
||||
if err := runOnly.RunE(runOnly, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runOnly.PostRun(runOnly, nil)
|
||||
if calls != 4 {
|
||||
t.Fatalf("hook calls=%d", calls)
|
||||
}
|
||||
|
||||
// A sink setup failure at Run entry returns before the business hook runs.
|
||||
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
|
||||
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
|
||||
businessRan := false
|
||||
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
|
||||
installOutputSinkRunBoundary(failCmd)
|
||||
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
|
||||
t.Fatalf("Run entry sink setup error=%v", err)
|
||||
}
|
||||
if businessRan {
|
||||
t.Fatal("business hook ran after sink setup failure")
|
||||
}
|
||||
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
|
||||
|
||||
// A Run entry business error aborts the open sink: the temporary file is
|
||||
// removed and the final target is never created.
|
||||
abortTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
abortCmd := newBoundaryChild(abortTarget)
|
||||
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
|
||||
installOutputSinkRunBoundary(abortCmd)
|
||||
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Run entry business error=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target exists after aborted run: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, abortTarget)
|
||||
|
||||
// A second configureOutputSink call on an already-open sink (a reused
|
||||
// command tree stacks one Run wrapper per ExecuteC) must not replace the
|
||||
// live sink with a second temporary file.
|
||||
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
repeatCmd := newBoundaryChild(repeatTarget)
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := outputSinkForCommand(repeatCmd)
|
||||
if first == nil {
|
||||
t.Fatal("first configureOutputSink did not open a sink")
|
||||
}
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second := outputSinkForCommand(repeatCmd); second != first {
|
||||
t.Fatal("configureOutputSink replaced an open sink")
|
||||
}
|
||||
if err := abortOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoOutputTemps(t, repeatTarget)
|
||||
|
||||
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorCmd := &cobra.Command{Use: "error"}
|
||||
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
|
||||
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
|
||||
t.Fatal("run error swallowed")
|
||||
}
|
||||
|
||||
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panicCmd := &cobra.Command{Use: "panic"}
|
||||
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("panic swallowed")
|
||||
}
|
||||
}()
|
||||
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
|
||||
}()
|
||||
|
||||
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
|
||||
t.Fatal("nil sink guards failed")
|
||||
}
|
||||
finished := &outputSinkState{finished: true, file: file3}
|
||||
finishedCmd := &cobra.Command{Use: "finished"}
|
||||
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
|
||||
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
|
||||
t.Fatal("finished sink was processed twice")
|
||||
}
|
||||
}
|
||||
|
||||
type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
cmd.SetOut(&stdout)
|
||||
}
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
return cmd
|
||||
})
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
|
||||
t.Run("preparse interrupted", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return errors.New("preparse failed")
|
||||
})
|
||||
if code := Execute(); code != 130 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil executed after emission attempt", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return nil, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure after emission", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failure envelope cannot be written", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late output publication warning", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, nil
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
unified bool
|
||||
original io.Writer
|
||||
wantOutput bool
|
||||
}{
|
||||
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
|
||||
{name: "legacy late publication failure", original: io.Discard},
|
||||
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
var original io.Writer = tc.original
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
panic("after sink open")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("abort failure is diagnostic", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "unified"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, finished: true,
|
||||
}))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, emitErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
|
||||
t.Run("flag group validation", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().Bool("left", false, "")
|
||||
leaf.Flags().Bool("right", false, "")
|
||||
leaf.MarkFlagsMutuallyExclusive("left", "right")
|
||||
root.AddCommand(leaf)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"exclusive", "--left", "--right"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected mutually-exclusive flag error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edition pre-run error", func(t *testing.T) {
|
||||
old := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(old) })
|
||||
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
|
||||
return errors.New("edition hook failed")
|
||||
}})
|
||||
root := NewRootCommand(context.Background())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post-run emission panic", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
cmd := &cobra.Command{Use: "panic-output"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(frameworkPanicWriter{})
|
||||
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected post-run panic")
|
||||
}
|
||||
}()
|
||||
_ = root.PersistentPostRunE(cmd, nil)
|
||||
})
|
||||
}
|
||||
+497
-25
@@ -15,24 +15,24 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -58,8 +59,14 @@ var (
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
rootCreateFile = os.Create
|
||||
rootCreateTemp = os.CreateTemp
|
||||
rootSyncFile = (*os.File).Sync
|
||||
rootCloseFile = (*os.File).Close
|
||||
// os.Rename replaces an existing non-directory target on every supported
|
||||
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
|
||||
// the temporary file beside the target so publication stays on one volume.
|
||||
rootRenameFile = os.Rename
|
||||
rootRemoveFile = os.Remove
|
||||
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||
@@ -76,10 +83,53 @@ var (
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
resultStore *output.ResultStore
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
|
||||
}
|
||||
} else if target != nil && output.UsesUnifiedResult(target) {
|
||||
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
|
||||
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
if executed == nil {
|
||||
executed = target
|
||||
}
|
||||
}
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
|
||||
if emitErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -95,15 +145,17 @@ func Execute() (exitCode int) {
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Attach timing collector to context for use by child components
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
ctx := WithTimingCollector(context.Background(), timing)
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -111,16 +163,89 @@ func Execute() (exitCode int) {
|
||||
// and --limit100 → --limit 100.
|
||||
if err := rootRunPreParse(root, engine); err != nil {
|
||||
err = newPreParseValidationError(err)
|
||||
if interrupted, _ := signalState.outcome(); interrupted != nil {
|
||||
err = interrupted
|
||||
}
|
||||
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
executed, err := rootExecuteCommand(root)
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
// errors to a defer that runs after the process exit code is fixed.
|
||||
if executed != nil {
|
||||
if err == nil {
|
||||
if closeErr := closeOutputSink(executed); closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
} else if abortErr := abortOutputSink(executed); abortErr != nil {
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
|
||||
}
|
||||
}
|
||||
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
|
||||
if interrupted != nil && !primaryCompletedBeforeSignal {
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if err != nil && stderrors.As(err, &publicationErr) {
|
||||
// The successful result was written only to a transaction that did
|
||||
// not publish. Let the error path replace it with one observable
|
||||
// failure envelope on the restored original stream.
|
||||
} else {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
|
||||
// Once publication starts, its stored exit code is authoritative. A
|
||||
// signal recorded just before or during publication must not turn a
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
if err == nil || !stderrors.As(err, &publicationErr) {
|
||||
err = interrupted
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -129,9 +254,121 @@ func Execute() (exitCode int) {
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||
// failure body. Exit code and category are derived from the same error value,
|
||||
// preventing the wire and process status from drifting apart.
|
||||
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
info := &output.ErrorInfo{
|
||||
Type: errorTypeForExitCode(exitCode),
|
||||
ExitCode: exitCode,
|
||||
Message: err.Error(),
|
||||
}
|
||||
var interrupted *processInterruption
|
||||
if stderrors.As(err, &interrupted) && interrupted != nil {
|
||||
info.Type = "internal"
|
||||
info.Subtype = interrupted.Subtype()
|
||||
return info
|
||||
}
|
||||
if stderrors.Is(err, context.DeadlineExceeded) {
|
||||
info.Subtype = "deadline_exceeded"
|
||||
}
|
||||
var cliErr *helpers.CLIError
|
||||
if stderrors.As(err, &cliErr) && cliErr != nil {
|
||||
info.UpstreamCode = cliErr.Code
|
||||
info.Hint = cliErr.Suggestion
|
||||
info.Operation = cliErr.Operation
|
||||
}
|
||||
var callErr *transport.CallError
|
||||
if stderrors.As(err, &callErr) && callErr != nil {
|
||||
info.HTTPStatus = callErr.HTTPStatus
|
||||
info.RPCCode = callErr.RPCCode
|
||||
info.Stage = string(callErr.Stage)
|
||||
if callErr.RequestID != "" {
|
||||
info.RequestID = callErr.RequestID
|
||||
} else if callErr.TraceID != "" {
|
||||
info.RequestID = callErr.TraceID
|
||||
}
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed == nil {
|
||||
return info
|
||||
}
|
||||
if typed.Category == apperrors.CategoryPartial {
|
||||
// An error lacks the item-level data required by partial_failure.
|
||||
// Callers must use output.Partial; fail closed consistently otherwise.
|
||||
info.Type = string(apperrors.CategoryInternal)
|
||||
} else {
|
||||
info.Type = string(typed.Category)
|
||||
}
|
||||
info.Subtype = typed.Reason
|
||||
if typed.Hint != "" {
|
||||
info.Hint = typed.Hint
|
||||
}
|
||||
info.Actions = append([]string(nil), typed.Actions...)
|
||||
info.Retryable = typed.RetryableSet && typed.Retryable
|
||||
info.RetryAfterSeconds = typed.RetryAfterSeconds
|
||||
if typed.RPCCode != 0 {
|
||||
info.RPCCode = typed.RPCCode
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
info.TraceID = typed.ServerDiag.TraceID
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
info.Operation = typed.Operation
|
||||
}
|
||||
info.ServerKey = typed.ServerKey
|
||||
info.Origin = typed.Origin
|
||||
if typed.FailureStage != "" {
|
||||
info.Stage = typed.FailureStage
|
||||
}
|
||||
info.ExecutionStarted = typed.ExecutionStarted
|
||||
if typed.NextRetryAt != nil {
|
||||
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
|
||||
info.SnapshotPath = typed.Snapshot
|
||||
info.Details = typed.Details
|
||||
if len(typed.RPCData) > 0 {
|
||||
var rpcData any
|
||||
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
|
||||
info.RPCData = rpcData
|
||||
}
|
||||
}
|
||||
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
|
||||
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
|
||||
if typed.Cause != nil {
|
||||
info.Cause = typed.Cause.Error()
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func errorTypeForExitCode(code int) string {
|
||||
switch code {
|
||||
case 1:
|
||||
return "api"
|
||||
case 2:
|
||||
return "auth"
|
||||
case 3:
|
||||
return "validation"
|
||||
case 4:
|
||||
return "permission"
|
||||
case 6:
|
||||
return "discovery"
|
||||
default:
|
||||
return "internal"
|
||||
}
|
||||
}
|
||||
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
@@ -368,6 +605,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||
}
|
||||
|
||||
@@ -390,6 +628,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||
}
|
||||
|
||||
@@ -414,6 +653,25 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// A public root may be reused by embedding callers through multiple
|
||||
// ExecuteC invocations. Begin each invocation with an empty result
|
||||
// lifecycle while retaining the store pointer observed by Execute's
|
||||
// signal and exit-code handling. Declaration-only command trees do not
|
||||
// install a store at construction time, so add one lazily when those
|
||||
// trees are executed for compatibility and policy tests.
|
||||
executionCtx, _ := output.WithResultStore(cmd.Context())
|
||||
cmd.SetContext(executionCtx)
|
||||
// WithResultStore above guarantees the reset precondition.
|
||||
_ = output.ResetResultStore(executionCtx)
|
||||
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
|
||||
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
|
||||
// rely on that order to normalize alias flags into required canonical
|
||||
// flags (for example chat message download-media copies --msg-id into
|
||||
// the required --message-id in PreRunE). Running them early fails the
|
||||
// alias path before the leaf can normalize it. The transactional
|
||||
// --output sink instead opens at Run entry (after Cobra's own
|
||||
// validation), so validation failures still cannot strand a
|
||||
// temporary file.
|
||||
// Validate caller-provided identity labels before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
@@ -436,19 +694,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
// Configure global slog level based on --debug / --verbose flags.
|
||||
configureLogLevel(flags)
|
||||
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
||||
return fn(cmd, args)
|
||||
if err := fn(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
_, emitted, emitErr := output.EmitStoredResult(cmd)
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
if emitErr != nil {
|
||||
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if output.UsesUnifiedResult(cmd) && !emitted {
|
||||
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
|
||||
}
|
||||
if closeErr := closeOutputSink(cmd); closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -848,6 +1124,54 @@ func deduplicateCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
type outputSinkState struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
original io.Writer
|
||||
tempPath string
|
||||
target string
|
||||
finished bool
|
||||
}
|
||||
|
||||
type outputPublicationError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *outputPublicationError) Error() string { return e.cause.Error() }
|
||||
func (e *outputPublicationError) Unwrap() error { return e.cause }
|
||||
func (e *outputPublicationError) ExitCode() int { return 5 }
|
||||
|
||||
func newOutputPublicationError(message string, cause error) error {
|
||||
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
|
||||
}
|
||||
|
||||
// emitOutputPublicationFailure replaces a result that was rendered only into a
|
||||
// rolled-back transactional file with one observable failure envelope on the
|
||||
// original output stream. This is not a second public result: closeOutputSink
|
||||
// has removed the temporary file and restored cmd.OutOrStdout before returning
|
||||
// the publication error.
|
||||
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
|
||||
var publicationErr *outputPublicationError
|
||||
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
|
||||
return 0, false, nil
|
||||
}
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
original := state.original
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if original == nil || !finished {
|
||||
return 0, false, nil
|
||||
}
|
||||
cmd.SetOut(original)
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
|
||||
code, emitErr = output.EmitResult(cmd, result)
|
||||
return code, true, emitErr
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -860,32 +1184,180 @@ func configureOutputSink(cmd *cobra.Command) error {
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
// A public root may be reused across ExecuteC calls, accumulating one Run
|
||||
// wrapper per execution. When the sink for this invocation is already open,
|
||||
// an inner wrapper must not replace it with a second temporary file.
|
||||
if state := outputSinkForCommand(cmd); state != nil {
|
||||
state.mu.Lock()
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if !finished {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||
}
|
||||
file, err := rootCreateFile(outputPath)
|
||||
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
|
||||
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
|
||||
}
|
||||
originalOut := cmd.OutOrStdout()
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file,
|
||||
original: originalOut,
|
||||
tempPath: file.Name(),
|
||||
target: outputPath,
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
// installOutputSinkRunBoundary defers opening the transactional --output sink
|
||||
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
|
||||
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
|
||||
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
|
||||
// still normalize alias flags into required canonical flags, and a validation
|
||||
// failure can never strand a temporary output file. Run-only leaves are
|
||||
// converted to RunE so a sink setup failure remains a returned error. Post-run
|
||||
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
|
||||
// transaction; pre-run hooks need no wrapping because the sink cannot exist
|
||||
// before Run entry.
|
||||
func installOutputSinkRunBoundary(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.RunE != nil {
|
||||
cmd.RunE = openSinkAndRun(cmd.RunE)
|
||||
} else if cmd.Run != nil {
|
||||
original := cmd.Run
|
||||
cmd.Run = nil
|
||||
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if cmd.PostRunE != nil {
|
||||
original := cmd.PostRunE
|
||||
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.PostRun != nil {
|
||||
original := cmd.PostRun
|
||||
cmd.PostRun = func(cmd *cobra.Command, args []string) {
|
||||
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
return run()
|
||||
}
|
||||
|
||||
func warnAbortOutputSink(cmd *cobra.Command) {
|
||||
if closeErr := abortOutputSink(cmd); closeErr != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func closeOutputSink(cmd *cobra.Command) error {
|
||||
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
|
||||
if !ok || file == nil {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if err := rootCloseFile(file); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
// A reusable Cobra tree must never retain the transactional file as its
|
||||
// stdout after this execution. Restore the caller's writer on every terminal
|
||||
// path, including sync/close/rename failures and repeated cleanup calls.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
if err := rootSyncFile(state.file); err != nil {
|
||||
_ = rootCloseFile(state.file)
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to sync output file", err)
|
||||
}
|
||||
if err := rootCloseFile(state.file); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to close output file", err)
|
||||
}
|
||||
if err := rootRenameFile(state.tempPath, state.target); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to publish output file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abortOutputSink(cmd *cobra.Command) error {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
// A business error still needs the root execution boundary to publish one
|
||||
// typed failure envelope. Restore the pre-transaction writer before closing
|
||||
// and unlinking the temporary file so that failure emission cannot target a
|
||||
// closed descriptor. The final --output target remains untouched.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
closeErr := rootCloseFile(state.file)
|
||||
removeErr := rootRemoveFile(state.tempPath)
|
||||
if closeErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
|
||||
}
|
||||
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
|
||||
if cmd == nil || cmd.Context() == nil {
|
||||
return nil
|
||||
}
|
||||
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
|
||||
if state == nil || state.file == nil {
|
||||
return nil
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func validateOptionalPath(flagName, path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
})
|
||||
|
||||
oldMkdir := rootMkdirAll
|
||||
oldCreate := rootCreateFile
|
||||
oldCreate := rootCreateTemp
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() {
|
||||
rootMkdirAll = oldMkdir
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
rootCloseFile = oldClose
|
||||
})
|
||||
wantErr := errors.New("filesystem")
|
||||
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
t.Fatal("mkdir failure succeeded")
|
||||
}
|
||||
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
||||
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
|
||||
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
||||
t.Fatal("create failure succeeded")
|
||||
}
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "close"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
|
||||
}))
|
||||
rootCloseFile = func(*os.File) error { return wantErr }
|
||||
if err := closeOutputSink(cmd); err == nil {
|
||||
t.Fatal("close failure succeeded")
|
||||
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
|
||||
}))
|
||||
if err := closeOutputSink(cmd); err != nil {
|
||||
t.Fatalf("close success = %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
|
||||
executed, err := root.ExecuteC()
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteC run %d: %v", want, err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Run int `json:"run"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
|
||||
}
|
||||
if !envelope.OK || envelope.Data.Run != want {
|
||||
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
|
||||
}
|
||||
}
|
||||
|
||||
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
|
||||
root.AddCommand(missing)
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-output-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
target := filepath.Join(t.TempDir(), "result.json")
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("first ExecuteC: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(target)
|
||||
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
|
||||
t.Fatalf("published output=%q err=%v", first, err)
|
||||
}
|
||||
|
||||
if err := root.PersistentFlags().Set("output", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("second ExecuteC: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"run": 2`) {
|
||||
t.Fatalf("second stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("late close diagnostic")
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil || apperrors.ExitCode(err) != 5 {
|
||||
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("executed=%v, want lifecycle leaf", executed)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("legacy close failed")
|
||||
}
|
||||
|
||||
root := NewRootCommandWithEngine(context.Background(), nil)
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
|
||||
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
|
||||
return errors.New("handler failed")
|
||||
}})
|
||||
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
|
||||
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
|
||||
if err := output.StoreResult(cmd.Context(), result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic("after emission")
|
||||
}
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
|
||||
}
|
||||
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
|
||||
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
|
||||
t.Fatalf("panic diagnostic missing: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
|
||||
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
|
||||
info := errorInfoFromExecutionError(err)
|
||||
if info.TraceID != "trace-1" || info.RequestID != "" {
|
||||
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempMode os.FileMode
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
info, err := cmd.OutOrStdout().(*os.File).Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempMode = info.Mode().Perm()
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC: %v", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "replacement", 0o600)
|
||||
if tempMode != 0o600 {
|
||||
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
return errors.New("handler failed")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
|
||||
target := os.Args[3]
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-failure",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
return apperrors.NewValidation("business validation failed")
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
|
||||
t.Fatalf("failure envelope=%+v", envelope)
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
panic("boom")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if recovered := executeAndRecover(root); recovered == nil {
|
||||
t.Fatal("ExecuteC did not panic")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
|
||||
t.Fatalf("ExecuteC error=%v, want publication failure", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seam func(*testing.T)
|
||||
}{
|
||||
{
|
||||
name: "sync",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("close failed")
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.seam(t)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-output-unified", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("unified ExecuteC succeeded without publishing its output")
|
||||
} else if code := apperrors.ExitCode(err); code != 5 {
|
||||
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final output exists after publication failure: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-publication",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
|
||||
t.Fatalf("publication failure envelope=%+v", envelope)
|
||||
}
|
||||
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
|
||||
t.Fatalf("publication failure message=%q", envelope.Error.Message)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-emission-failure",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.OutOrStdout().(*os.File).Close()
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded after emission failure")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().String("required", "", "")
|
||||
_ = leaf.MarkFlagRequired("required")
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-validation", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded without required flag")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
|
||||
root := NewRootCommand()
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "atomic-output",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return run(cmd)
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
func executeAndRecover(cmd *cobra.Command) (recovered any) {
|
||||
defer func() { recovered = recover() }()
|
||||
_, _ = cmd.ExecuteC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("output=%q, want %q", data, want)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat output: %v", err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != wantMode {
|
||||
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoOutputTemps(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary output files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
|
||||
// regression for the alias normalization order: root's persistent pre-run must
|
||||
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
|
||||
// chat message download-media copies --msg-id / --open-message-id into the
|
||||
// required --message-id flag in its PreRunE; validating early failed that
|
||||
// documented alias path with "missing required flag(s): --message-id".
|
||||
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
|
||||
for _, alias := range []string{"msg-id", "open-message-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
target := filepath.Join(t.TempDir(), "download.bin")
|
||||
root.SetArgs([]string{
|
||||
"chat", "message", "download-media",
|
||||
"--type", "mediaId",
|
||||
"--resource-id", "media-1",
|
||||
"--" + alias, "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
"--output", target,
|
||||
"--dry-run", "--format", "json",
|
||||
})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
executed := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
|
||||
executed.SetOut(&stdout)
|
||||
executed.SetErr(&stderr)
|
||||
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
|
||||
executed.SetContext(root.Context())
|
||||
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(executed); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
return executed, nil
|
||||
}
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
|
||||
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
|
||||
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
|
||||
// writer 不得收到任何字节。
|
||||
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
|
||||
// 失败走 stderr(PrintHuman),stdout 为空。
|
||||
func TestRootHumanErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
_ = root.PersistentFlags().Set("format", "table")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Error:") {
|
||||
t.Fatalf("expected human error on stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
|
||||
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
|
||||
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
|
||||
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// ok / pending(信封 success/pending 语义)→ 0
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("success Execute code = %d, want 0", code)
|
||||
}
|
||||
|
||||
// An error cannot carry partial succeeded/failed data and fails closed.
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("partial error Execute code = %d, want 5", code)
|
||||
}
|
||||
|
||||
// confirmation_required(validation 子类)→ 3
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("confirmation Execute code = %d, want 3", code)
|
||||
}
|
||||
|
||||
// plain internal → 5
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, errors.New("plain")
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("plain Execute code = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
|
||||
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
|
||||
// (StopAllStdioClients)在错误路径也被调用。
|
||||
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
stopped := false
|
||||
rootStopAllStdioClients = func() { stopped = true }
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewInternal("fail")
|
||||
}
|
||||
_ = Execute()
|
||||
if !stopped {
|
||||
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
|
||||
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
|
||||
func TestRootSilenceErrorsFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
if !root.SilenceErrors || !root.SilenceUsage {
|
||||
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,32 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
if !containsSchemaPath(report.Covered, "chat category create-smart") {
|
||||
t.Fatal("chat category create-smart is not covered by runtime Schema")
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if !containsSchemaPath(report.Covered, path) {
|
||||
t.Fatalf("%s is not covered by runtime Schema", path)
|
||||
}
|
||||
}
|
||||
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
|
||||
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excluded := map[string]bool{}
|
||||
for _, exclusion := range exclusions {
|
||||
excluded[exclusion.CLIPath] = true
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if excluded[path] {
|
||||
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSchemaPath(paths []string, want string) bool {
|
||||
for _, path := range paths {
|
||||
if path == want {
|
||||
@@ -38,3 +59,38 @@ func containsSchemaPath(paths []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingChatCatalogCoveragePaths() []string {
|
||||
return []string{
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,14 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 187; got != want {
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
shortcutCount := 0
|
||||
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
|
||||
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
|
||||
shortcutCount++
|
||||
}
|
||||
@@ -127,6 +129,135 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
for _, cliPath := range missingChatCatalogCoveragePaths() {
|
||||
if summaryByCLIPath[cliPath] == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
}
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
cliPath string,
|
||||
effect string,
|
||||
risk string,
|
||||
confirmation string,
|
||||
) {
|
||||
t.Helper()
|
||||
summary := summaries[cliPath]
|
||||
if summary == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
if got := schemaContractString(summary["effect"]); got != effect {
|
||||
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
|
||||
}
|
||||
if got := schemaContractString(summary["risk"]); got != risk {
|
||||
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
|
||||
}
|
||||
if got := schemaContractString(summary["confirmation"]); got != confirmation {
|
||||
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
t.Helper()
|
||||
for _, cliPath := range []string{
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat hide",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
} {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
|
||||
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
|
||||
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
|
||||
})
|
||||
}
|
||||
|
||||
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
|
||||
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
|
||||
|
||||
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
|
||||
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, chmod, "chat chmod")
|
||||
|
||||
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
|
||||
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
|
||||
"require_one_of": [][]string{{"target-org-id", "all"}},
|
||||
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
|
||||
|
||||
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
|
||||
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
|
||||
"require_one_of": [][]string{{"target", "receiver"}},
|
||||
"mutually_exclusive": [][]string{{"target", "receiver"}},
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got, _ := parameter["required"].(bool); got != want {
|
||||
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
|
||||
t.Helper()
|
||||
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
grantType := parameters["grant-type"]
|
||||
if grantType == nil {
|
||||
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
|
||||
}
|
||||
wantEnum := []string{"once", "session", "timed", "permanent"}
|
||||
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
|
||||
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
|
||||
}
|
||||
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
|
||||
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
|
||||
}
|
||||
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
|
||||
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
var rootEscalateSignal = func(sig os.Signal) {
|
||||
signal.Reset(sig)
|
||||
redeliverProcessSignal(sig)
|
||||
}
|
||||
|
||||
var (
|
||||
rootFindProcess = os.FindProcess
|
||||
rootExitProcess = os.Exit
|
||||
)
|
||||
|
||||
// redeliverProcessSignal asks the current process to handle the second signal
|
||||
// with the platform's default semantics. Platforms that cannot deliver the
|
||||
// requested signal through os.Process.Signal fall back to the conventional
|
||||
// CLI exit status instead of leaving the process running after escalation.
|
||||
func redeliverProcessSignal(sig os.Signal) {
|
||||
process, err := rootFindProcess(os.Getpid())
|
||||
if err == nil {
|
||||
err = process.Signal(sig)
|
||||
}
|
||||
if err != nil {
|
||||
rootExitProcess(interruptionExitCode(sig))
|
||||
}
|
||||
}
|
||||
|
||||
func interruptionExitCode(sig os.Signal) int {
|
||||
if sig == syscall.SIGTERM {
|
||||
return 143
|
||||
}
|
||||
return 130
|
||||
}
|
||||
|
||||
type processInterruption struct {
|
||||
signal os.Signal
|
||||
}
|
||||
|
||||
func (e *processInterruption) Error() string {
|
||||
return fmt.Sprintf("process interrupted by %s", e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Unwrap() error { return context.Canceled }
|
||||
|
||||
func (e *processInterruption) ExitCode() int {
|
||||
return interruptionExitCode(e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Subtype() string {
|
||||
if e.signal == syscall.SIGTERM {
|
||||
return "terminated"
|
||||
}
|
||||
return "cancelled_by_user"
|
||||
}
|
||||
|
||||
type processSignalState struct {
|
||||
mu sync.Mutex
|
||||
interruption *processInterruption
|
||||
primaryCompletedAtSignal bool
|
||||
}
|
||||
|
||||
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.interruption != nil {
|
||||
return false
|
||||
}
|
||||
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
|
||||
s.interruption = &processInterruption{signal: sig}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *processSignalState) outcome() (*processInterruption, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.interruption, s.primaryCompletedAtSignal
|
||||
}
|
||||
|
||||
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
|
||||
}
|
||||
|
||||
func manageProcessSignals(
|
||||
parent context.Context,
|
||||
store *output.ResultStore,
|
||||
signals <-chan os.Signal,
|
||||
stopNotify func(),
|
||||
escalate func(os.Signal),
|
||||
) (context.Context, *processSignalState, func()) {
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
state := &processSignalState{}
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
for {
|
||||
select {
|
||||
case sig := <-signals:
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
if state.record(sig, store) {
|
||||
cancel(state.interruption)
|
||||
continue
|
||||
}
|
||||
escalate(sig)
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
stopNotify()
|
||||
close(done)
|
||||
<-stopped
|
||||
cancel(context.Canceled)
|
||||
})
|
||||
}
|
||||
return ctx, state, stop
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func signalSelf(t *testing.T, sig syscall.Signal) {
|
||||
t.Helper()
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("find current process: %v", err)
|
||||
}
|
||||
if err := process.Signal(sig); err != nil {
|
||||
t.Skipf("current platform does not support process signal delivery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
|
||||
originalFind, originalExit := rootFindProcess, rootExitProcess
|
||||
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
|
||||
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
|
||||
exitCode := 0
|
||||
rootExitProcess = func(code int) { exitCode = code }
|
||||
rootEscalateSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("escalation exit=%d", exitCode)
|
||||
}
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("fallback exit=%d", exitCode)
|
||||
}
|
||||
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGINT)
|
||||
if exitCode != 130 {
|
||||
t.Fatalf("signal fallback exit=%d", exitCode)
|
||||
}
|
||||
interrupted := &processInterruption{signal: syscall.SIGINT}
|
||||
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
|
||||
t.Fatalf("interruption=%v", interrupted)
|
||||
}
|
||||
terminated := &processInterruption{signal: syscall.SIGTERM}
|
||||
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
|
||||
t.Fatalf("termination=%v", terminated)
|
||||
}
|
||||
state := &processSignalState{}
|
||||
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
|
||||
t.Fatal("signal state did not reject a second interruption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
|
||||
signals := make(chan os.Signal, 3)
|
||||
stopped, escalated := false, make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
|
||||
signals <- nil
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated=%v", got)
|
||||
}
|
||||
stop()
|
||||
stop()
|
||||
if !stopped {
|
||||
t.Fatal("signal notification was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
if unified {
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
}
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, &stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
|
||||
}
|
||||
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
|
||||
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
signal syscall.Signal
|
||||
code int
|
||||
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
|
||||
t.Run(tc.signal.String(), func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
return cmd, context.DeadlineExceeded
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
|
||||
t.Fatalf("deadline error=%+v", env.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(failingWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want publication failure code 5", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("second envelope emitted: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
// Record cancellation before publication begins, then simulate a command
|
||||
// hook that has already committed its result and completes publication.
|
||||
// The wire result must remain authoritative over the earlier signal.
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want published success code 0", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if !env.OK || env.Outcome != output.OutcomeSuccess {
|
||||
t.Fatalf("published envelope=%+v, want successful outcome", env)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want established success code 0", code)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
|
||||
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
|
||||
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "READY")
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
os.Exit(Execute())
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
|
||||
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
|
||||
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
|
||||
}
|
||||
if err := cmd.Process.Signal(tc.signal); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
|
||||
}
|
||||
payload, readErr := io.ReadAll(stdout)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
|
||||
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
t.Fatalf("decode helper output: %v; output=%q", err, payload)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("helper error=%+v", env.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
escalated := make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
|
||||
escalated <- sig
|
||||
})
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
if !errors.Is(context.Cause(ctx), context.Canceled) {
|
||||
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
|
||||
}
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated %v, want SIGTERM", got)
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,11 +22,13 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("mode", "", "usage")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
|
||||
@@ -35,6 +38,10 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
Effect: "write", Confirmation: "user_required", Idempotency: "none",
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string","description":"Created object ID"}}}`),
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "from contract",
|
||||
UseWhen: []string{"create things"},
|
||||
@@ -68,6 +75,9 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("dry_run = %#v", spec.DryRun)
|
||||
}
|
||||
if spec.Result == nil || string(spec.Result.DataSchema) != `{"properties":{"id":{"type":"string","description":"Created object ID"}},"type":"object"}` {
|
||||
t.Fatalf("result = %#v", spec.Result)
|
||||
}
|
||||
if spec.Selection.AgentSummary != "from contract" {
|
||||
t.Fatalf("selection = %#v", spec.Selection)
|
||||
}
|
||||
@@ -79,6 +89,39 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
|
||||
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
output.SetCommandRollout(cmd, state)
|
||||
cmd.Flags().String("cursor", "", "cursor")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "cursor", "cursor", "string", false)
|
||||
final := contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "list_things", CanonicalPath: "dev.list_things",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list",
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev", ToolName: "list_things", CLIName: "list",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list", ProductName: "Dev", Command: cmd,
|
||||
}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Result != nil || spec.Pagination != nil {
|
||||
t.Fatalf("inactive rollout published result=%#v pagination=%#v", spec.Result, spec.Pagination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
|
||||
@@ -1059,6 +1059,7 @@ var schemaCompactPayloadKeys = map[string]bool{
|
||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"result": true, "pagination": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
@@ -1074,8 +1075,8 @@ var schemaCompactParamKeys = map[string]bool{
|
||||
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
// recursively; result, constraint, positional and dry-run values are already
|
||||
// typed contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
|
||||
@@ -78,7 +78,9 @@ var schemaCatalogToolOptionalKeys = []string{
|
||||
"interface_reason",
|
||||
"interface_ref",
|
||||
"metadata_source",
|
||||
"pagination",
|
||||
"positionals",
|
||||
"result",
|
||||
}
|
||||
|
||||
var schemaCatalogToolEnums = map[string][]string{
|
||||
@@ -237,6 +239,37 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
|
||||
}
|
||||
|
||||
validateCatalogInterface(toolID, entry, violations)
|
||||
if result, exists := entry["result"]; exists {
|
||||
if _, ok := result.(map[string]any); !ok {
|
||||
report("field %q must be an object", "result")
|
||||
}
|
||||
}
|
||||
if rawPagination, exists := entry["pagination"]; exists {
|
||||
pagination, ok := rawPagination.(map[string]any)
|
||||
if !ok {
|
||||
report("field %q must be an object", "pagination")
|
||||
} else {
|
||||
want := map[string]string{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
for field, expected := range want {
|
||||
if value, _ := pagination[field].(string); value != expected {
|
||||
report("field %q.%s = %q, want %q", "pagination", field, value, expected)
|
||||
}
|
||||
}
|
||||
cursor, _ := pagination["cursor_parameter"].(string)
|
||||
if strings.TrimSpace(cursor) == "" {
|
||||
report("field %q.cursor_parameter must be a non-empty string", "pagination")
|
||||
} else if paramsOK {
|
||||
if _, exists := parameters[cursor]; !exists {
|
||||
report("field %q.cursor_parameter references missing parameter %q", "pagination", cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for paramName, raw := range parameters {
|
||||
param, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// TestDeliverySchemaCatalogStructure gates the delivered catalog: every tool
|
||||
@@ -100,6 +102,90 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
|
||||
entry := validCatalogToolEntry()
|
||||
entry["result"] = map[string]any{
|
||||
"outcomes": []any{"success", "failure"},
|
||||
"data_schema": map[string]any{"type": "object"},
|
||||
}
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v", err)
|
||||
}
|
||||
entry["result"] = "invalid"
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), `field "result" must be an object`) {
|
||||
t.Fatalf("invalid result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureAcceptsStandalonePagination(t *testing.T) {
|
||||
entry := validCatalogToolEntry()
|
||||
parameters := entry["parameters"].(map[string]any)
|
||||
parameters["cursor"] = map[string]any{
|
||||
"description": "续页游标",
|
||||
"field_provenance": map[string]any{},
|
||||
"required": false,
|
||||
"type": "string",
|
||||
}
|
||||
entry["parameter_count"] = float64(len(parameters))
|
||||
entry["has_parameters"] = true
|
||||
entry["pagination"] = map[string]any{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v", err)
|
||||
}
|
||||
|
||||
entry["pagination"].(map[string]any)["next_token_path"] = "data.nextCursor"
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), "next_token_path") {
|
||||
t.Fatalf("invalid pagination error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureRejectsMalformedStandalonePagination(t *testing.T) {
|
||||
validPaginationEntry := func() map[string]any {
|
||||
entry := validCatalogToolEntry()
|
||||
parameters := entry["parameters"].(map[string]any)
|
||||
parameters["cursor"] = map[string]any{
|
||||
"description": "续页游标",
|
||||
"field_provenance": map[string]any{},
|
||||
"required": false,
|
||||
"type": "string",
|
||||
}
|
||||
entry["parameter_count"] = float64(len(parameters))
|
||||
entry["pagination"] = map[string]any{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
want string
|
||||
}{
|
||||
{"not an object", func(entry map[string]any) { entry["pagination"] = "cursor" }, `field "pagination" must be an object`},
|
||||
{"empty cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = " " }, "cursor_parameter must be a non-empty string"},
|
||||
{"unknown cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = "page-token" }, "references missing parameter"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := validPaginationEntry()
|
||||
tc.mutate(entry)
|
||||
err := ValidateCatalogStructure(catalogPayload(t, entry))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCatalogStructureRejectsViolations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -33,8 +33,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"auth status",
|
||||
"completion",
|
||||
"config list",
|
||||
"dev connect list",
|
||||
"dev connect restart",
|
||||
"doctor",
|
||||
"plugin build",
|
||||
"plugin config get",
|
||||
@@ -93,40 +91,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"calendar acl add",
|
||||
"calendar acl delete",
|
||||
"calendar book update",
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
"contact label get",
|
||||
"contact label list",
|
||||
"contact label list-members",
|
||||
"dev app version check-approval",
|
||||
"ding message list",
|
||||
"ding message recall-personal",
|
||||
"ding message receiver-status",
|
||||
|
||||
@@ -38,20 +38,33 @@ func init() {
|
||||
registerRequireTogether("calendar.update_calendar_event", "recurrence-type", "recurrence-interval", "recurrence-range-type")
|
||||
registerExclusiveOneOf("chat.search_messages_by_sender", "sender-user-id", "sender-open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
|
||||
RegisterRuntimeSchemaConstraints("chat.chat_permission_grant", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
registerExclusiveOneOf("chat.share_group_invite_url", "target", "receiver")
|
||||
registerExclusiveOneOf("chat.transfer_group_owner", "new-owner", "user")
|
||||
registerRequireOneOf("chat.update_conv_member_roles", "users", "user")
|
||||
registerExclusiveOneOf("chat.update_at_all_notification_off", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_notification_off", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.update_red_env_notification_off", "conversation-id", "id", "chat")
|
||||
registerRequireTogether("contact.query_dismission_employee_list", "start", "end")
|
||||
registerRequireOneOf("dev.connect_status", "robot-client-id", "unified-app-id")
|
||||
registerRequireOneOf("dev.connect_stop", "robot-client-id", "unified-app-id")
|
||||
|
||||
@@ -60,6 +60,8 @@ type ToolSpec struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
Interface contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
@@ -133,6 +135,8 @@ type RuntimeToolSpecInput struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
Interface contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
@@ -538,6 +542,20 @@ func (t ToolSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Result != nil {
|
||||
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !seen[pagination.CursorParameter] {
|
||||
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
|
||||
}
|
||||
}
|
||||
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
|
||||
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
|
||||
return err
|
||||
@@ -726,6 +744,18 @@ func (t ToolSpec) normalized() ToolSpec {
|
||||
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
|
||||
out.DryRun = &dryRun
|
||||
}
|
||||
if t.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
|
||||
if err == nil {
|
||||
out.Result = result
|
||||
}
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
|
||||
if err == nil {
|
||||
out.Pagination = pagination
|
||||
}
|
||||
}
|
||||
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
|
||||
sort.Slice(out.Positionals, func(i, j int) bool {
|
||||
if out.Positionals[i].Index != out.Positionals[j].Index {
|
||||
@@ -952,6 +982,14 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
|
||||
value, _ := typedJSONValue(t.DryRun)
|
||||
payload["dry_run"] = value
|
||||
}
|
||||
if t.Result != nil {
|
||||
value, _ := typedJSONValue(t.Result)
|
||||
payload["result"] = value
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
value, _ := typedJSONValue(t.Pagination)
|
||||
payload["pagination"] = value
|
||||
}
|
||||
applySafetyPayload(payload, t.Safety)
|
||||
applyInterfacePayload(payload, t.Interface)
|
||||
applySelectionPayload(payload, t.Selection, true)
|
||||
@@ -975,7 +1013,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
|
||||
}
|
||||
for _, key := range []string{
|
||||
"parameters", "has_parameters", "parameter_count", "constraints",
|
||||
"positionals", "examples", "effect_source", "agent_source_refs",
|
||||
"positionals", "result", "examples", "effect_source", "agent_source_refs",
|
||||
"field_provenance", "path", "source", "product_id", "display", "is_alias",
|
||||
} {
|
||||
delete(payload, key)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestResultContractModelWireRoundTripAndCompactPolicy(t *testing.T) {
|
||||
result := &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{ "type":"object", "properties":{"items":{"type":"array","description":"Business result records","items":{"type":"object"}}} }`),
|
||||
SensitivePaths: []string{"items.secret", "credential"},
|
||||
}
|
||||
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"},
|
||||
Parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
|
||||
Result: result,
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
|
||||
}
|
||||
if got, want := spec.Result.Outcomes, []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
result.Outcomes[0] = contract.ResultOutcomePending
|
||||
result.DataSchema[0] = '['
|
||||
if spec.Result.Outcomes[0] != contract.ResultOutcomeSuccess || spec.Result.DataSchema[0] != '{' {
|
||||
t.Fatal("ToolSpec result aliases runtime input")
|
||||
}
|
||||
|
||||
payload, err := spec.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPayload() error = %v", err)
|
||||
}
|
||||
resultPayload, ok := payload["result"].(map[string]any)
|
||||
if !ok || schemaString(resultPayload["data_schema"].(map[string]any)["type"]) != "object" {
|
||||
t.Fatalf("result payload = %#v", payload["result"])
|
||||
}
|
||||
if _, exists := specResultSummary(t, spec)["result"]; exists {
|
||||
t.Fatal("result must remain full-leaf-only")
|
||||
}
|
||||
compactResult, exists := stripSchemaPayloadCompact(payload)["result"].(map[string]any)
|
||||
if !exists {
|
||||
t.Fatal("compact leaf must include the reviewed result contract")
|
||||
}
|
||||
if outcomes, ok := compactResult["outcomes"].([]any); !ok || len(outcomes) != 2 {
|
||||
t.Fatalf("compact result outcomes = %#v", compactResult["outcomes"])
|
||||
}
|
||||
if dataSchema, ok := compactResult["data_schema"].(map[string]any); !ok || schemaString(dataSchema["type"]) != "object" {
|
||||
t.Fatalf("compact result data_schema = %#v", compactResult["data_schema"])
|
||||
}
|
||||
if !reflect.DeepEqual(compactResult, resultPayload) {
|
||||
t.Fatalf("compact result must equal full-leaf result\ncompact: %#v\nfull: %#v", compactResult, resultPayload)
|
||||
}
|
||||
compactPagination, exists := stripSchemaPayloadCompact(payload)["pagination"].(map[string]any)
|
||||
if !exists || schemaString(compactPagination["meta_path"]) != contract.PaginationMetaPath || schemaString(compactPagination["cursor_parameter"]) != "cursor" {
|
||||
t.Fatalf("compact pagination = %#v", compactPagination)
|
||||
}
|
||||
|
||||
wire, err := schemaToolWireFromPayload(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
|
||||
}
|
||||
roundTrip, err := schemaToolSpecFromWire(wire)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaToolSpecFromWire() error = %v", err)
|
||||
}
|
||||
roundTripPayload, err := roundTrip.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("round-trip ToPayload() error = %v", err)
|
||||
}
|
||||
if !schemaJSONEqual(payload, roundTripPayload) {
|
||||
t.Fatalf("result changed across wire round-trip\nfirst: %#v\nround: %#v", payload["result"], roundTripPayload["result"])
|
||||
}
|
||||
}
|
||||
|
||||
func specResultSummary(t *testing.T, spec ToolSpec) map[string]any {
|
||||
t.Helper()
|
||||
payload, err := spec.ToSummaryPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSummaryPayload() error = %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestToolWithoutResultKeepsResultAbsent(t *testing.T) {
|
||||
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "legacy", CLIName: "legacy", CLIPath: "dev legacy"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := spec.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := payload["result"]; exists {
|
||||
t.Fatal("tool without Result gained a result key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSpecRejectsInvalidResultInsteadOfDroppingIt(t *testing.T) {
|
||||
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "invalid", CLIName: "invalid", CLIPath: "dev invalid"},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`[]`),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("invalid result schema was silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSpecRejectsInvalidOrUndeclaredPaginationCursor(t *testing.T) {
|
||||
identity := contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
parameters []ParameterSpec
|
||||
pagination *contract.PaginationSpec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid pagination declaration",
|
||||
parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
|
||||
pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
|
||||
want: "unsupported kind",
|
||||
},
|
||||
{
|
||||
name: "cursor is not a parameter",
|
||||
pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
want: "is not a declared parameter",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: identity, Parameters: tc.parameters, Pagination: tc.pagination,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ToolSpecFromRuntime() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -334,6 +335,15 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
|
||||
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
|
||||
|
||||
result, pagination := final.Result, final.Pagination
|
||||
if !output.UsesUnifiedResult(entry.Command) {
|
||||
// ResultSpec describes the unified envelope data value and PaginationSpec
|
||||
// describes meta.pagination. Keep both declarations internal while a
|
||||
// command still emits legacy bytes or only shadow-validates the new
|
||||
// contract; publishing them early makes Schema disagree with runtime.
|
||||
result, pagination = nil, nil
|
||||
}
|
||||
|
||||
return ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: identity,
|
||||
Display: entry.ProductName,
|
||||
@@ -344,6 +354,8 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
Constraints: constraints,
|
||||
Positionals: positionals,
|
||||
DryRun: final.DryRun,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Safety: safety,
|
||||
Interface: interfaceSpec,
|
||||
Selection: selection,
|
||||
|
||||
@@ -65,6 +65,8 @@ type schemaToolWire struct {
|
||||
Constraints RuntimeSchemaConstraints `json:"constraints"`
|
||||
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
|
||||
DryRun *contract.DryRunSpec `json:"dry_run"`
|
||||
Result *contract.ResultSpec `json:"result"`
|
||||
Pagination *contract.PaginationSpec `json:"pagination"`
|
||||
Effect string `json:"effect"`
|
||||
EffectSource string `json:"effect_source"`
|
||||
Risk string `json:"risk"`
|
||||
@@ -267,6 +269,8 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
|
||||
Constraints: wire.Constraints,
|
||||
Positionals: wire.Positionals,
|
||||
DryRun: wire.DryRun,
|
||||
Result: wire.Result,
|
||||
Pagination: wire.Pagination,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: wire.Effect,
|
||||
EffectSource: wire.EffectSource,
|
||||
|
||||
@@ -30,6 +30,8 @@ type ContractFinalPayload struct {
|
||||
Parameters []ParamDecl
|
||||
Safety *SafetySpec
|
||||
DryRun *DryRunSpec
|
||||
Result *ResultSpec
|
||||
Pagination *PaginationSpec
|
||||
Interface *InterfaceSpec
|
||||
Selection *SelectionSpec
|
||||
Identity *ToolIdentitySpec
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeResultSpecCanonicalizesAndCopies(t *testing.T) {
|
||||
in := &ResultSpec{
|
||||
Outcomes: []ResultOutcome{ResultOutcomeFailure, ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{ "properties": {"items":{"type":"array","description":"Result records","items":{"type":"object"}}}, "type":"object" }`),
|
||||
SensitivePaths: []string{"items.secret", "credential"},
|
||||
}
|
||||
|
||||
got, err := NormalizeResultSpec(in, "dev.list")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeResultSpec() error = %v", err)
|
||||
}
|
||||
if want := []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeFailure}; !reflect.DeepEqual(got.Outcomes, want) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", got.Outcomes, want)
|
||||
}
|
||||
if string(got.DataSchema) != `{"properties":{"items":{"type":"array","description":"Result records","items":{"type":"object"}}},"type":"object"}` {
|
||||
t.Fatalf("data_schema = %s", got.DataSchema)
|
||||
}
|
||||
if want := []string{"credential", "items.secret"}; !reflect.DeepEqual(got.SensitivePaths, want) {
|
||||
t.Fatalf("sensitive_paths = %#v, want %#v", got.SensitivePaths, want)
|
||||
}
|
||||
|
||||
in.Outcomes[0] = ResultOutcomePending
|
||||
in.DataSchema[0] = '['
|
||||
in.SensitivePaths[0] = "changed"
|
||||
if got.Outcomes[0] != ResultOutcomeSuccess || got.DataSchema[0] != '{' || got.SensitivePaths[0] != "credential" {
|
||||
t.Fatalf("normalized result aliases input: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeResultSpecRejectsInvalidContractsDeterministically(t *testing.T) {
|
||||
valid := func() *ResultSpec {
|
||||
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*ResultSpec)
|
||||
want string
|
||||
}{
|
||||
{"no outcomes", func(r *ResultSpec) { r.Outcomes = nil }, "no outcomes"},
|
||||
{"unknown outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{"ok"} }, "unknown outcome"},
|
||||
{"duplicate outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeSuccess} }, "duplicate outcome"},
|
||||
{"schema array", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`[]`) }, "data_schema: must be one JSON object"},
|
||||
{"multiple schemas", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`{} {}`) }, "data_schema: must be one JSON object"},
|
||||
{"missing property description", func(r *ResultSpec) {
|
||||
r.DataSchema = json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)
|
||||
}, "properties.id requires description"},
|
||||
{"unsafe path", func(r *ResultSpec) { r.SensitivePaths = []string{"$.token"} }, "unsafe segment"},
|
||||
{"duplicate path", func(r *ResultSpec) { r.SensitivePaths = []string{"token", " token "} }, "duplicate sensitive path"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
spec := valid()
|
||||
test.edit(spec)
|
||||
_, err := NormalizeResultSpec(spec, "dev.test")
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationSpecUsesFrameworkMetaPaths(t *testing.T) {
|
||||
got, err := NormalizePaginationSpec(&PaginationSpec{Kind: PaginationKindCursor, CursorParameter: "--cursor"}, "dev.list")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.CursorParameter != "cursor" || got.MetaPath != PaginationMetaPath || got.EndpointExhaustedPath != PaginationExhaustedPath || got.NextTokenPath != PaginationNextTokenPath {
|
||||
t.Fatalf("pagination = %#v", got)
|
||||
}
|
||||
for _, spec := range []*PaginationSpec{
|
||||
{Kind: "offset", CursorParameter: "cursor"},
|
||||
{Kind: PaginationKindCursor},
|
||||
{Kind: PaginationKindCursor, CursorParameter: "cursor", MetaPath: "data.pagination"},
|
||||
} {
|
||||
if _, err := NormalizePaginationSpec(spec, "dev.list"); err == nil {
|
||||
t.Fatalf("invalid pagination accepted: %#v", spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -59,6 +62,242 @@ type DryRunSpec struct {
|
||||
RemoteReads bool `json:"remote_reads,omitempty"`
|
||||
}
|
||||
|
||||
// ResultOutcome is one closed unified-output envelope outcome.
|
||||
type ResultOutcome string
|
||||
|
||||
const (
|
||||
ResultOutcomeSuccess ResultOutcome = "success"
|
||||
ResultOutcomePending ResultOutcome = "pending"
|
||||
ResultOutcomePartialFailure ResultOutcome = "partial_failure"
|
||||
ResultOutcomeFailure ResultOutcome = "failure"
|
||||
)
|
||||
|
||||
var canonicalResultOutcomes = [...]ResultOutcome{
|
||||
ResultOutcomeSuccess,
|
||||
ResultOutcomePending,
|
||||
ResultOutcomePartialFailure,
|
||||
ResultOutcomeFailure,
|
||||
}
|
||||
|
||||
const (
|
||||
PaginationKindCursor = "cursor"
|
||||
PaginationMetaPath = "meta.pagination"
|
||||
PaginationExhaustedPath = "meta.pagination.endpoint_exhausted"
|
||||
PaginationNextTokenPath = "meta.pagination.next_token"
|
||||
)
|
||||
|
||||
// PaginationSpec is a command-level declaration for framework pagination
|
||||
// metadata. It is deliberately separate from ResultSpec because pagination is
|
||||
// emitted under envelope meta, not inside the business response data.
|
||||
type PaginationSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
CursorParameter string `json:"cursor_parameter"`
|
||||
MetaPath string `json:"meta_path"`
|
||||
EndpointExhaustedPath string `json:"endpoint_exhausted_path"`
|
||||
NextTokenPath string `json:"next_token_path"`
|
||||
}
|
||||
|
||||
// ResultSpec is the reviewed return-value contract for one command and is
|
||||
// projected unchanged into both full-leaf and compact-leaf Schema. Outcomes
|
||||
// and DataSchema are required; Pagination and SensitivePaths are omitted when
|
||||
// absent. DataSchema is a canonical recursive JSON Schema object; every path
|
||||
// is relative to the unified-output envelope data value.
|
||||
type ResultSpec struct {
|
||||
Outcomes []ResultOutcome `json:"outcomes"`
|
||||
DataSchema json.RawMessage `json:"data_schema"`
|
||||
SensitivePaths []string `json:"sensitive_paths,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeResultSpec returns a validated, canonical, defensively copied
|
||||
// result contract. It is shared by declaration, ToolSpec, and snapshot paths.
|
||||
func NormalizeResultSpec(in *ResultSpec, canonical string) (*ResultSpec, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||
out := &ResultSpec{}
|
||||
seenOutcomes := make(map[ResultOutcome]bool, len(in.Outcomes))
|
||||
for _, outcome := range in.Outcomes {
|
||||
outcome = ResultOutcome(strings.TrimSpace(string(outcome)))
|
||||
valid := false
|
||||
for _, allowed := range canonicalResultOutcomes {
|
||||
if outcome == allowed {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("schema tool %s result has unknown outcome %q", canonical, outcome)
|
||||
}
|
||||
if seenOutcomes[outcome] {
|
||||
return nil, fmt.Errorf("schema tool %s result has duplicate outcome %q", canonical, outcome)
|
||||
}
|
||||
seenOutcomes[outcome] = true
|
||||
}
|
||||
if len(seenOutcomes) == 0 {
|
||||
return nil, fmt.Errorf("schema tool %s result has no outcomes", canonical)
|
||||
}
|
||||
for _, outcome := range canonicalResultOutcomes {
|
||||
if seenOutcomes[outcome] {
|
||||
out.Outcomes = append(out.Outcomes, outcome)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
out.DataSchema, err = canonicalJSONObject(in.DataSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result data_schema: %w", canonical, err)
|
||||
}
|
||||
if err := validateResultSchemaDescriptions(out.DataSchema, "data_schema"); err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result %w", canonical, err)
|
||||
}
|
||||
seenPaths := make(map[string]bool, len(in.SensitivePaths))
|
||||
for _, path := range in.SensitivePaths {
|
||||
path = strings.TrimSpace(path)
|
||||
if err := validateResultPath(path); err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result sensitive path: %w", canonical, err)
|
||||
}
|
||||
if seenPaths[path] {
|
||||
return nil, fmt.Errorf("schema tool %s result has duplicate sensitive path %q", canonical, path)
|
||||
}
|
||||
seenPaths[path] = true
|
||||
out.SensitivePaths = append(out.SensitivePaths, path)
|
||||
}
|
||||
sort.Strings(out.SensitivePaths)
|
||||
if len(out.SensitivePaths) == 0 {
|
||||
out.SensitivePaths = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalizePaginationSpec validates the command-specific input parameter and
|
||||
// fills the framework-owned public meta paths.
|
||||
func NormalizePaginationSpec(in *PaginationSpec, canonical string) (*PaginationSpec, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||
kind := strings.TrimSpace(in.Kind)
|
||||
if kind != PaginationKindCursor {
|
||||
return nil, fmt.Errorf("schema tool %s pagination has unsupported kind %q", canonical, kind)
|
||||
}
|
||||
cursorParameter := strings.TrimSpace(strings.TrimPrefix(in.CursorParameter, "--"))
|
||||
if cursorParameter == "" || strings.Contains(cursorParameter, ".") {
|
||||
return nil, fmt.Errorf("schema tool %s pagination cursor_parameter must name one CLI flag", canonical)
|
||||
}
|
||||
provided := []struct{ name, got, want string }{
|
||||
{"meta_path", strings.TrimSpace(in.MetaPath), PaginationMetaPath},
|
||||
{"endpoint_exhausted_path", strings.TrimSpace(in.EndpointExhaustedPath), PaginationExhaustedPath},
|
||||
{"next_token_path", strings.TrimSpace(in.NextTokenPath), PaginationNextTokenPath},
|
||||
}
|
||||
for _, field := range provided {
|
||||
if field.got != "" && field.got != field.want {
|
||||
return nil, fmt.Errorf("schema tool %s pagination %s is framework-owned and must be %q", canonical, field.name, field.want)
|
||||
}
|
||||
}
|
||||
return &PaginationSpec{
|
||||
Kind: kind,
|
||||
CursorParameter: cursorParameter,
|
||||
MetaPath: PaginationMetaPath,
|
||||
EndpointExhaustedPath: PaginationExhaustedPath,
|
||||
NextTokenPath: PaginationNextTokenPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var object map[string]json.RawMessage
|
||||
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
canonical, _ := json.Marshal(object) // decoded RawMessages are always marshalable
|
||||
return json.RawMessage(canonical), nil
|
||||
}
|
||||
|
||||
// validateResultSchemaDescriptions keeps the Agent-facing return contract
|
||||
// self-explanatory. Every named property needs a description; nested object
|
||||
// properties and array items are checked recursively. The root schema and
|
||||
// anonymous composition branches do not need descriptions because they are
|
||||
// not field names an Agent must interpret.
|
||||
func validateResultSchemaDescriptions(raw json.RawMessage, location string) error {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return fmt.Errorf("%s must be one JSON Schema object", location)
|
||||
}
|
||||
return validateResultSchemaNode(schema, location)
|
||||
}
|
||||
|
||||
func validateResultSchemaNode(schema map[string]any, location string) error {
|
||||
if rawProperties, exists := schema["properties"]; exists {
|
||||
properties, ok := rawProperties.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.properties must be an object", location)
|
||||
}
|
||||
for name, rawProperty := range properties {
|
||||
property, ok := rawProperty.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.properties.%s must be a JSON Schema object", location, name)
|
||||
}
|
||||
description, _ := property["description"].(string)
|
||||
if strings.TrimSpace(description) == "" {
|
||||
return fmt.Errorf("%s.properties.%s requires description", location, name)
|
||||
}
|
||||
if err := validateResultSchemaNode(property, location+".properties."+name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawItems, exists := schema["items"]; exists {
|
||||
items, ok := rawItems.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.items must be a JSON Schema object", location)
|
||||
}
|
||||
if err := validateResultSchemaNode(items, location+".items"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, keyword := range []string{"allOf", "anyOf", "oneOf"} {
|
||||
rawBranches, exists := schema[keyword]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
branches, ok := rawBranches.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s must be an array", location, keyword)
|
||||
}
|
||||
for index, rawBranch := range branches {
|
||||
branch, ok := rawBranch.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s[%d] must be a JSON Schema object", location, keyword, index)
|
||||
}
|
||||
if err := validateResultSchemaNode(branch, fmt.Sprintf("%s.%s[%d]", location, keyword, index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateResultPath(path string) error {
|
||||
if path == "" || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") || strings.Contains(path, "..") {
|
||||
return fmt.Errorf("path %q is not a relative data path", path)
|
||||
}
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
for i, r := range segment {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (i > 0 && (r == '-' || (r >= '0' && r <= '9')))) {
|
||||
return fmt.Errorf("path %q contains unsafe segment %q", path, segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
DryRunPreviewInvocation = "invocation"
|
||||
DryRunPreviewRequest = "request"
|
||||
|
||||
@@ -4,10 +4,75 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameworkResultSpecValidationEdges(t *testing.T) {
|
||||
if got, err := NormalizeResultSpec(nil, ""); err != nil || got != nil {
|
||||
t.Fatalf("NormalizeResultSpec(nil)=(%v,%v)", got, err)
|
||||
}
|
||||
base := func() *ResultSpec {
|
||||
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
if got, err := NormalizeResultSpec(base(), ""); err != nil || got == nil || got.SensitivePaths != nil {
|
||||
t.Fatalf("valid default spec=(%#v,%v)", got, err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(*ResultSpec)
|
||||
}{
|
||||
{"sensitive invalid", func(s *ResultSpec) { s.SensitivePaths = []string{"bad..path"} }},
|
||||
{"empty schema", func(s *ResultSpec) { s.DataSchema = nil }},
|
||||
{"multiple schema", func(s *ResultSpec) { s.DataSchema = json.RawMessage(`{} {}`) }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := base()
|
||||
tc.edit(spec)
|
||||
if _, err := NormalizeResultSpec(spec, "sample"); err == nil {
|
||||
t.Fatalf("invalid spec accepted: %#v", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := validateResultPath("a.$"); err == nil {
|
||||
t.Fatal("unsafe segment accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultSchemaDescriptionValidationEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"invalid json", `{`, "must be one JSON Schema object"},
|
||||
{"properties is not object", `{"properties":[]}`, "properties must be an object"},
|
||||
{"property is not schema", `{"properties":{"id":"string"}}`, "properties.id must be a JSON Schema object"},
|
||||
{"property description missing", `{"properties":{"id":{"type":"string"}}}`, "properties.id requires description"},
|
||||
{"nested property invalid", `{"properties":{"item":{"description":"item","properties":[]}}}`, "properties.item.properties must be an object"},
|
||||
{"items is not schema", `{"items":[]}`, "items must be a JSON Schema object"},
|
||||
{"nested items invalid", `{"items":{"properties":[]}}`, "items.properties must be an object"},
|
||||
{"composition is not array", `{"oneOf":{}}`, "oneOf must be an array"},
|
||||
{"composition branch is not schema", `{"anyOf":["string"]}`, "anyOf[0] must be a JSON Schema object"},
|
||||
{"nested composition invalid", `{"allOf":[{"properties":[]}]}`, "allOf[0].properties must be an object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateResultSchemaDescriptions(json.RawMessage(tc.raw), "data_schema")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationSpecNilIsAbsent(t *testing.T) {
|
||||
if got, err := NormalizePaginationSpec(nil, ""); err != nil || got != nil {
|
||||
t.Fatalf("NormalizePaginationSpec(nil) = (%#v, %v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDryRunSpecValidate(t *testing.T) {
|
||||
for _, kind := range []string{DryRunPreviewInvocation, DryRunPreviewRequest, DryRunPreviewPlan, DryRunPreviewDiff} {
|
||||
if err := (DryRunSpec{PreviewKind: kind}).Validate("sample.run"); err != nil {
|
||||
|
||||
@@ -42,6 +42,8 @@ type ContractDecl struct {
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
Parameters []contract.ParamDecl
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Interface *contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
Identity contract.ToolIdentitySpec
|
||||
@@ -144,6 +146,12 @@ func (s ContractDecl) empty() bool {
|
||||
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
|
||||
return false
|
||||
}
|
||||
if s.Result != nil {
|
||||
return false
|
||||
}
|
||||
if s.Pagination != nil {
|
||||
return false
|
||||
}
|
||||
if s.Interface != nil {
|
||||
iface := s.Interface
|
||||
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
Description: "Create Desc",
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`),
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
@@ -79,6 +84,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
|
||||
t.Fatalf("dry_run = %#v", final.DryRun)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) != 1 {
|
||||
t.Fatalf("result = %#v", final.Result)
|
||||
}
|
||||
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
||||
t.Fatalf("pagination = %#v", final.Pagination)
|
||||
}
|
||||
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
|
||||
t.Fatalf("interface = %#v", final.Interface)
|
||||
}
|
||||
@@ -110,6 +121,54 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkContractDeclResultMarksNonEmptyAndRejectsInvalidSchema(t *testing.T) {
|
||||
if (ContractDecl{Result: &contract.ResultSpec{}}).Empty() {
|
||||
t.Fatal("Result declaration was treated as empty")
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Result") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "bad-result",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: ContractDecl{
|
||||
Title: "Bad", Description: "bad result",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-result"}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad", CanonicalPath: "sample.bad", CLIPath: "bad-result", PrimaryCLIPath: "bad-result"},
|
||||
},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrameworkContractDeclPaginationMarksNonEmptyAndRejectsInvalidSpec(t *testing.T) {
|
||||
if (ContractDecl{Pagination: &contract.PaginationSpec{}}).Empty() {
|
||||
t.Fatal("Pagination declaration was treated as empty")
|
||||
}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Pagination") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "bad-pagination",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: ContractDecl{
|
||||
Title: "Bad pagination",
|
||||
Description: "bad pagination",
|
||||
Pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-pagination"}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad_pagination", CanonicalPath: "sample.bad_pagination", CLIPath: "bad-pagination", PrimaryCLIPath: "bad-pagination"},
|
||||
},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewCommandFallsBackToDeclaredDescriptionWithoutLong(t *testing.T) {
|
||||
// Long wins when authored; without one the mandatory declaration supplies it.
|
||||
cmd := New(Spec{
|
||||
|
||||
@@ -24,6 +24,11 @@ import (
|
||||
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||
result := &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`),
|
||||
SensitivePaths: []string{"token"},
|
||||
}
|
||||
|
||||
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Title: "T",
|
||||
@@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
},
|
||||
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
|
||||
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
|
||||
Result: result,
|
||||
})
|
||||
result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||
result.DataSchema[0] = '['
|
||||
result.SensitivePaths[0] = "changed"
|
||||
if cmd.Annotations != nil {
|
||||
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
|
||||
t.Fatal("must not write JSON annotation dws.schema.final")
|
||||
@@ -45,6 +54,14 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
if got.Selection == nil || got.Selection.Reviewed != nil {
|
||||
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
|
||||
}
|
||||
if got.Result == nil || got.Result.Outcomes[0] != contract.ResultOutcomeSuccess || got.Result.DataSchema[0] != '{' || got.Result.SensitivePaths[0] != "token" {
|
||||
t.Fatalf("stored result aliases registration input: %#v", got.Result)
|
||||
}
|
||||
got.Result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||
again, _ := RuntimeContractFinal(cmd)
|
||||
if again.Result.Outcomes[0] != contract.ResultOutcomeSuccess {
|
||||
t.Fatal("RuntimeContractFinal result aliases stored payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
|
||||
@@ -137,3 +154,96 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t
|
||||
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *testing.T) {
|
||||
read := &cobra.Command{Use: "read"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(read) })
|
||||
RegisterRuntimeContractFinal(read, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.read", PrimaryCLIPath: "sample get"},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", Idempotency: " idempotent "},
|
||||
})
|
||||
|
||||
for _, lookup := range []struct {
|
||||
canonical string
|
||||
cli string
|
||||
}{
|
||||
{canonical: "sample.read"},
|
||||
{canonical: "different.rpc", cli: "dws sample get"},
|
||||
} {
|
||||
safety, declared, ok := ResolveRuntimeSafety(lookup.canonical, lookup.cli)
|
||||
if !declared || !ok || safety.Effect != "read" || safety.Idempotency != "idempotent" {
|
||||
t.Fatalf("ResolveRuntimeSafety(%q, %q) = %#v, %v, %v", lookup.canonical, lookup.cli, safety, declared, ok)
|
||||
}
|
||||
}
|
||||
|
||||
missingSafety := &cobra.Command{Use: "write"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(missingSafety) })
|
||||
RegisterRuntimeContractFinal(missingSafety, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.write"},
|
||||
})
|
||||
if _, declared, ok := ResolveRuntimeSafety("sample.write", ""); !declared || ok {
|
||||
t.Fatalf("missing safety = declared %v ok %v, want true false", declared, ok)
|
||||
}
|
||||
if _, declared, ok := ResolveRuntimeSafety("legacy.call", "legacy call"); declared || ok {
|
||||
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "all"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||
payload := contract.ContractFinalPayload{
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id"}},
|
||||
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{Ref: &contract.InterfaceRefSpec{}},
|
||||
Selection: &contract.SelectionSpec{
|
||||
UseWhen: []string{"use"}, AvoidWhen: []string{"avoid"}, Prerequisites: []string{"pre"}, Tips: []string{"tip"},
|
||||
WorkflowRefs: []string{"flow"}, Examples: []string{"example"}, SourceRefs: []string{"source"},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{Index: intPointer(1)}}, Reviewed: boolPointer(true),
|
||||
},
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all", Aliases: []string{"alias"}},
|
||||
}
|
||||
RegisterRuntimeContractFinal(cmd, payload)
|
||||
got, ok := RuntimeContractFinal(cmd)
|
||||
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
|
||||
t.Fatalf("payload not deeply cloned: %#v", got)
|
||||
}
|
||||
payload.Parameters[0].Enum[0] = "changed"
|
||||
*payload.Parameters[0].Required = false
|
||||
*payload.Selection.ExampleDispositions[0].Index = 9
|
||||
*payload.Selection.Reviewed = false
|
||||
again, _ := RuntimeContractFinal(cmd)
|
||||
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
|
||||
t.Fatalf("stored payload aliased input: %#v", again)
|
||||
}
|
||||
|
||||
matching := &cobra.Command{Use: "matching"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
|
||||
RegisterRuntimeContractFinal(matching, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{Path: "sample.all", CLIPath: "sample all"}, Safety: &contract.SafetySpec{Effect: "read", EffectSource: "source", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || !valid {
|
||||
t.Fatalf("equivalent duplicate=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
|
||||
conflict := &cobra.Command{Use: "conflict"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(conflict) })
|
||||
RegisterRuntimeContractFinal(conflict, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all"}, Safety: &contract.SafetySpec{Effect: "write"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || valid {
|
||||
t.Fatalf("conflict=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
if runtimeIdentityMatches(contract.ToolIdentitySpec{}, "", "") {
|
||||
t.Fatal("empty identity matched")
|
||||
}
|
||||
if got := cloneSlice[string](nil); got != nil {
|
||||
t.Fatalf("cloneSlice(nil)=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package contractfinal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF
|
||||
return
|
||||
}
|
||||
runtimeannotate.AnnotateRuntimeContract(cmd)
|
||||
p := payload
|
||||
p := cloneContractFinalPayload(payload)
|
||||
contractFinalByCommand.Store(cmd, &p)
|
||||
}
|
||||
|
||||
@@ -51,7 +52,84 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo
|
||||
if !ok || p == nil {
|
||||
return contract.ContractFinalPayload{}, false
|
||||
}
|
||||
return *p, true
|
||||
return cloneContractFinalPayload(*p), true
|
||||
}
|
||||
|
||||
func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.ContractFinalPayload {
|
||||
out := in
|
||||
out.Positionals = cloneSlice(in.Positionals)
|
||||
out.Parameters = cloneSlice(in.Parameters)
|
||||
for i := range out.Parameters {
|
||||
out.Parameters[i].Enum = cloneSlice(in.Parameters[i].Enum)
|
||||
if in.Parameters[i].Required != nil {
|
||||
required := *in.Parameters[i].Required
|
||||
out.Parameters[i].Required = &required
|
||||
}
|
||||
}
|
||||
if in.Safety != nil {
|
||||
value := *in.Safety
|
||||
out.Safety = &value
|
||||
}
|
||||
if in.DryRun != nil {
|
||||
value := *in.DryRun
|
||||
out.DryRun = &value
|
||||
}
|
||||
if in.Result != nil {
|
||||
value := *in.Result
|
||||
value.Outcomes = cloneSlice(in.Result.Outcomes)
|
||||
value.DataSchema = cloneSlice(in.Result.DataSchema)
|
||||
value.SensitivePaths = cloneSlice(in.Result.SensitivePaths)
|
||||
out.Result = &value
|
||||
}
|
||||
if in.Pagination != nil {
|
||||
value := *in.Pagination
|
||||
out.Pagination = &value
|
||||
}
|
||||
if in.Interface != nil {
|
||||
value := *in.Interface
|
||||
if in.Interface.Ref != nil {
|
||||
ref := *in.Interface.Ref
|
||||
value.Ref = &ref
|
||||
}
|
||||
out.Interface = &value
|
||||
}
|
||||
if in.Selection != nil {
|
||||
value := *in.Selection
|
||||
value.UseWhen = cloneSlice(in.Selection.UseWhen)
|
||||
value.AvoidWhen = cloneSlice(in.Selection.AvoidWhen)
|
||||
value.Prerequisites = cloneSlice(in.Selection.Prerequisites)
|
||||
value.Tips = cloneSlice(in.Selection.Tips)
|
||||
value.WorkflowRefs = cloneSlice(in.Selection.WorkflowRefs)
|
||||
value.Examples = cloneSlice(in.Selection.Examples)
|
||||
value.SourceRefs = cloneSlice(in.Selection.SourceRefs)
|
||||
value.ExampleDispositions = cloneSlice(in.Selection.ExampleDispositions)
|
||||
for i := range value.ExampleDispositions {
|
||||
if in.Selection.ExampleDispositions[i].Index != nil {
|
||||
index := *in.Selection.ExampleDispositions[i].Index
|
||||
value.ExampleDispositions[i].Index = &index
|
||||
}
|
||||
}
|
||||
if in.Selection.Reviewed != nil {
|
||||
reviewed := *in.Selection.Reviewed
|
||||
value.Reviewed = &reviewed
|
||||
}
|
||||
out.Selection = &value
|
||||
}
|
||||
if in.Identity != nil {
|
||||
value := *in.Identity
|
||||
value.Aliases = cloneSlice(in.Identity.Aliases)
|
||||
out.Identity = &value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSlice[T any](in []T) []T {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]T, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
|
||||
@@ -62,3 +140,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool {
|
||||
_, ok := contractFinalByCommand.Load(cmd)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResolveRuntimeSafety finds the live ContractFinal safety declaration for an
|
||||
// invocation identity. declared distinguishes a matched declaration whose
|
||||
// safety is unavailable or conflicting from a legacy invocation with no unified
|
||||
// declaration context. Repeated equivalent command-tree registrations are
|
||||
// accepted; conflicting matches fail closed with ok=false.
|
||||
func ResolveRuntimeSafety(canonicalPath, cliPath string) (safety contract.SafetySpec, declared, ok bool) {
|
||||
canonicalPath = strings.TrimSpace(canonicalPath)
|
||||
cliPath = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cliPath), "dws "))
|
||||
|
||||
var resolved contract.SafetySpec
|
||||
contractFinalByCommand.Range(func(_, raw any) bool {
|
||||
payload, valid := raw.(*contract.ContractFinalPayload)
|
||||
if !valid || payload == nil || payload.Identity == nil ||
|
||||
!runtimeIdentityMatches(*payload.Identity, canonicalPath, cliPath) {
|
||||
return true
|
||||
}
|
||||
declared = true
|
||||
if payload.Safety == nil {
|
||||
ok = false
|
||||
return false
|
||||
}
|
||||
candidate := normalizedRuntimeSafety(*payload.Safety)
|
||||
if !ok {
|
||||
resolved = candidate
|
||||
ok = true
|
||||
return true
|
||||
}
|
||||
if resolved != candidate {
|
||||
ok = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return resolved, declared, ok
|
||||
}
|
||||
|
||||
func runtimeIdentityMatches(identity contract.ToolIdentitySpec, canonicalPath, cliPath string) bool {
|
||||
if canonicalPath != "" {
|
||||
for _, value := range []string{identity.CanonicalPath, identity.Path} {
|
||||
if strings.TrimSpace(value) == canonicalPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if cliPath == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range []string{identity.PrimaryCLIPath, identity.CLIPath} {
|
||||
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "dws ")) == cliPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedRuntimeSafety(safety contract.SafetySpec) contract.SafetySpec {
|
||||
safety.Effect = strings.TrimSpace(safety.Effect)
|
||||
safety.EffectSource = strings.TrimSpace(safety.EffectSource)
|
||||
safety.Risk = strings.TrimSpace(safety.Risk)
|
||||
safety.Confirmation = strings.TrimSpace(safety.Confirmation)
|
||||
safety.Idempotency = strings.TrimSpace(safety.Idempotency)
|
||||
return safety
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
@@ -224,11 +225,12 @@ const (
|
||||
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
||||
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||||
type Spec struct {
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
OutputRollout output.RolloutState
|
||||
|
||||
Flags []FlagSpec
|
||||
Constraints []Constraint
|
||||
@@ -266,6 +268,8 @@ type Spec struct {
|
||||
RunE func(cmd *cobra.Command, args []string) error
|
||||
// Invoke executes a single-step command with the assembled toolArgs.
|
||||
Invoke func(c *Ctx, toolArgs map[string]any) error
|
||||
// ResultInvoke executes once and returns an immutable framework 2.0 result.
|
||||
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, error)
|
||||
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
||||
// it needs from the Ctx.
|
||||
Orchestrate func(c *Ctx) error
|
||||
@@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command {
|
||||
if spec.PostMount != nil {
|
||||
spec.PostMount(cmd)
|
||||
}
|
||||
if spec.OutputRollout != "" {
|
||||
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||
}
|
||||
if spec.ConfirmFirst {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
@@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ResultInvoke != nil {
|
||||
if !output.UsesUnifiedResult(cmd) {
|
||||
return fmt.Errorf("command %q uses ResultInvoke without an active unified-result rollout", cmd.CommandPath())
|
||||
}
|
||||
result, err := spec.ResultInvoke(ctx, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.StoreResult(cmd.Context(), result)
|
||||
}
|
||||
return spec.Invoke(ctx, toolArgs)
|
||||
}
|
||||
return cmd
|
||||
@@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) {
|
||||
if spec.Invoke != nil {
|
||||
declared++
|
||||
}
|
||||
if spec.ResultInvoke != nil {
|
||||
declared++
|
||||
}
|
||||
if spec.Orchestrate != nil {
|
||||
declared++
|
||||
}
|
||||
if declared != 1 {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
|
||||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d (ResultInvoke is also a dispatcher)",
|
||||
spec.Use, declared))
|
||||
}
|
||||
// ConfirmFirst only changes the ordering of a declared confirmation gate.
|
||||
@@ -681,7 +701,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
|
||||
if hint == "" {
|
||||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||||
}
|
||||
return fmt.Errorf("%s", hint)
|
||||
return apperrors.NewValidation(hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1360,6 +1380,20 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
||||
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
||||
payload.DryRun = &d
|
||||
}
|
||||
if decl.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
|
||||
}
|
||||
payload.Result = result
|
||||
}
|
||||
if decl.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(decl.Pagination, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Pagination: %v", cmd.Name(), err))
|
||||
}
|
||||
payload.Pagination = pagination
|
||||
}
|
||||
if decl.Interface != nil {
|
||||
iface := &contract.InterfaceSpec{
|
||||
Mode: strings.TrimSpace(decl.Interface.Mode),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
|
||||
calls := 0
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd := New(Spec{
|
||||
Use: "result",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
calls++
|
||||
return output.Success(map[string]any{"id": "a"}), nil
|
||||
},
|
||||
})
|
||||
cmd.SetContext(ctx)
|
||||
cmd.PersistentFlags().String("format", "json", "")
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("calls=%d, want 1", calls)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
if output.CommandRollout(cmd) != output.RolloutUnifiedActive {
|
||||
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultInvokeErrorLegacyAndStoreEdges(t *testing.T) {
|
||||
wantErr := errors.New("invoke failed")
|
||||
cases := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
invoke func(*Ctx, map[string]any) (output.CommandResult, error)
|
||||
want string
|
||||
}{
|
||||
{"invoke error", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return nil, wantErr }, "invoke failed"},
|
||||
{"legacy guard", output.RolloutLegacyOnly, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "without an active unified-result rollout"},
|
||||
{"missing store", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "no result store"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := New(Spec{Use: "result", OutputRollout: tc.rollout, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, ResultInvoke: tc.invoke})
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute error=%v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyResultInvokeIsRejectedBeforeBusinessDispatch(t *testing.T) {
|
||||
calls := 0
|
||||
cmd := New(Spec{
|
||||
Use: "result",
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
calls++
|
||||
return output.Success(map[string]any{"changed": true}), nil
|
||||
},
|
||||
})
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "without an active unified-result rollout") {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("business dispatcher ran %d time(s), want 0", calls)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,11 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
t.Cleanup(func() { marshalErrorJSON = oldMarshal })
|
||||
marshalErrorJSON = func(any, string, string) ([]byte, error) { return nil, stderrors.New("encode") }
|
||||
out.Reset()
|
||||
if err := PrintJSON(&out, err); err != nil || !strings.Contains(out.String(), "failed to encode") {
|
||||
if err := PrintJSON(&out, err); err != nil ||
|
||||
!strings.Contains(out.String(), `"code":5`) ||
|
||||
!strings.Contains(out.String(), `"category":"internal"`) ||
|
||||
strings.Contains(out.String(), `"outcome"`) ||
|
||||
strings.Contains(out.String(), `"type"`) {
|
||||
t.Fatalf("PrintJSON fallback = %q, %v", out.String(), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,41 @@ const (
|
||||
CategoryValidation Category = "validation"
|
||||
CategoryDiscovery Category = "discovery"
|
||||
CategoryInternal Category = "internal"
|
||||
|
||||
// CategoryPartial is retained for source compatibility, but an error cannot
|
||||
// reconstruct the per-item data required by a partial result. It therefore
|
||||
// fails closed as internal; callers must use output.Partial for exit code 7.
|
||||
CategoryPartial Category = "partial_failure"
|
||||
)
|
||||
|
||||
// 退出码表(规划 v1.2 OQ-1 定案;契约规范 §4;轮10裁决⑬——保留现行码表,
|
||||
// 仅新增 partial_failure 专用码,不做 wire 破坏性重排):
|
||||
//
|
||||
// 0 success / pending(异步受理不是失败)
|
||||
// 1 api (CategoryAPI)
|
||||
// 2 auth (CategoryAuth)
|
||||
// 3 validation (CategoryValidation;confirmation_required 子类共享此码,
|
||||
// 以 reason/subtype 区分,AC-13)
|
||||
// 4 PAT (PATError 专属,见 pat.go ExitCodePermission;Category 不占用)
|
||||
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
|
||||
// 6 discovery (CategoryDiscovery)
|
||||
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
|
||||
//
|
||||
// ExitCodePartial is the partial-result exit code shared with internal/output.
|
||||
// It is not returned for CategoryPartial errors because they lack the typed
|
||||
// succeeded/failed/unknown payload required for an honest partial result.
|
||||
const ExitCodePartial = 7
|
||||
|
||||
// 类别专属退出码常量(B171/B172,权威 = 规划 v1.2 OQ-1 定案,契约规范 §4)。
|
||||
// ExitCode() 的 switch 用内联字面量,本组常量由 exitcodes.go 的
|
||||
// exitCodeByCategory 映射表引用,值与内联字面量一一对应(同源不双轨)。
|
||||
// 修改任一值必须先同步 ExitCode() 的 switch 分支与 internal/output 侧码表。
|
||||
const (
|
||||
ExitCodeAPI = 1
|
||||
ExitCodeAuth = 2
|
||||
ExitCodeValidation = 3
|
||||
ExitCodeDiscovery = 6
|
||||
ExitCodeInternal = 5
|
||||
)
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
@@ -83,6 +118,13 @@ type Option func(*Error)
|
||||
// ExitCodePermission and the exit-code table in docs/reference.md);
|
||||
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
||||
// apart from "PAT permission insufficient".
|
||||
//
|
||||
// confirmation_required 是 validation 的子类而非独立类别(B171,AC-13,
|
||||
// 规划 v1.2 OQ-1 定案):门禁拦截错误挂 CategoryValidation 并以
|
||||
// reason=confirmation_required 区分,与 validation 共享 rc=3。信封侧
|
||||
// internal/output exitCodeForErrorInfo 的「subtype 优先于 type、
|
||||
// confirmation_required 恒 3」规则与本表同源(轮10裁决⑬;远期独立码
|
||||
// 保留于规划 OQ-9,落地前不得双轨)。
|
||||
func (e *Error) ExitCode() int {
|
||||
switch e.Category {
|
||||
case CategoryAPI:
|
||||
@@ -93,6 +135,10 @@ func (e *Error) ExitCode() int {
|
||||
return 3
|
||||
case CategoryDiscovery:
|
||||
return 6
|
||||
case CategoryPartial:
|
||||
// An error has no per-item succeeded/failed/unknown data and therefore
|
||||
// cannot truthfully represent partial_failure. Fail closed as internal.
|
||||
return ExitCodeInternal
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
@@ -149,6 +195,11 @@ func WithRetryable(retryable bool) Option {
|
||||
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
||||
// A zero delay is meaningful and is therefore preserved; negative values are
|
||||
// ignored as invalid server guidance.
|
||||
//
|
||||
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
|
||||
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
|
||||
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
|
||||
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
|
||||
func WithRetryAfterSeconds(seconds int64) Option {
|
||||
return func(err *Error) {
|
||||
if seconds < 0 {
|
||||
@@ -322,14 +373,17 @@ func ExitCode(err error) int {
|
||||
return 5
|
||||
}
|
||||
|
||||
// PrintJSON writes a machine-readable JSON error object.
|
||||
// PrintJSON writes the legacy machine-readable JSON error object.
|
||||
//
|
||||
// This wire predates the unified result framework and is intentionally kept
|
||||
// byte-compatible for commands whose rollout is legacy_only or dual_validate.
|
||||
// Unified commands publish outcome/type/subtype through internal/output only.
|
||||
func PrintJSON(w io.Writer, err error) error {
|
||||
errorPayload := map[string]any{
|
||||
"code": ExitCode(err),
|
||||
"category": category(err),
|
||||
"message": err.Error(),
|
||||
}
|
||||
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
if typed.Reason != "" {
|
||||
@@ -409,7 +463,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
|
||||
data, marshalErr := marshalErrorJSON(payload, "", " ")
|
||||
if marshalErr != nil {
|
||||
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
||||
_, writeErr := fmt.Fprintln(w, `{"error":{"code":5,"category":"internal","message":"failed to encode error output"}}`)
|
||||
return writeErr
|
||||
}
|
||||
|
||||
@@ -556,6 +610,12 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return friendlyHint, actionURL
|
||||
}
|
||||
|
||||
// ServerGuidance exposes the same recovery projection to repository-local
|
||||
// adapters so legacy JSON and unified-result errors stay semantically aligned.
|
||||
func ServerGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return serverGuidance(diag)
|
||||
}
|
||||
|
||||
func safeServerActionURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -572,6 +632,9 @@ func safeServerActionURL(raw string) string {
|
||||
func category(err error) string {
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
if typed.Category == CategoryPartial {
|
||||
return string(CategoryInternal)
|
||||
}
|
||||
return string(typed.Category)
|
||||
}
|
||||
return string(CategoryInternal)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package errors
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestErrorsExitCodeMapConsistentWithExitCode 是 B209 的 errors 侧同源锁定:
|
||||
// exitcodes.go 的 exitCodeByCategory 映射表必须与 ExitCode() 的 switch 分支
|
||||
// 逐类别一致(同源不双轨,契约 §4)。任一单边修改即失败,防止未来漂移。
|
||||
// output 侧同源锁定由 internal/output emitter_phase_c_test.go
|
||||
// TestExitCodeForEnvelopeSameSourceAsErrorsExitCode 交叉断言(B209)。
|
||||
func TestErrorsExitCodeMapConsistentWithExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cats := []Category{
|
||||
CategoryAPI,
|
||||
CategoryAuth,
|
||||
CategoryValidation,
|
||||
CategoryDiscovery,
|
||||
CategoryInternal,
|
||||
CategoryPartial,
|
||||
}
|
||||
for _, cat := range cats {
|
||||
table := exitCodeByCategory[cat]
|
||||
viaSwitch := (&Error{Category: cat, Message: "x"}).ExitCode()
|
||||
if table != viaSwitch {
|
||||
t.Fatalf("exitCodeByCategory[%q]=%d disagrees with ExitCode()=%d", cat, table, viaSwitch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsExitCodeConstantsEqualMap 锁定类别专属常量与映射表值一致。
|
||||
func TestErrorsExitCodeConstantsEqualMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[Category]int{
|
||||
CategoryAPI: ExitCodeAPI,
|
||||
CategoryAuth: ExitCodeAuth,
|
||||
CategoryValidation: ExitCodeValidation,
|
||||
CategoryDiscovery: ExitCodeDiscovery,
|
||||
CategoryInternal: ExitCodeInternal,
|
||||
CategoryPartial: ExitCodeInternal,
|
||||
}
|
||||
for cat, wantCode := range want {
|
||||
if got := exitCodeByCategory[cat]; got != wantCode {
|
||||
t.Fatalf("exitCodeByCategory[%q]=%d, want %d", cat, got, wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestErrorsPrintJSONFieldInventory protects the published legacy error wire.
|
||||
// Unified type/subtype/outcome fields belong to internal/output and must not
|
||||
// leak into commands that have not migrated.
|
||||
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"too many requests",
|
||||
WithReason("rate_limit"),
|
||||
WithHint("wait and retry"),
|
||||
WithRetryable(true),
|
||||
WithRetryAfterSeconds(30),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`"category": "api"`,
|
||||
`"reason": "rate_limit"`,
|
||||
`"code": 1`,
|
||||
`"retryable": true`,
|
||||
`"retry_after_seconds": 30`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing wire-stable field %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
// informational 组
|
||||
for _, want := range []string{
|
||||
`"message": "too many requests"`,
|
||||
`"hint": "wait and retry"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing informational field %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"outcome"`, `"type"`, `"subtype"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Errorf("unified field %s leaked into legacy wire: %s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONLegacyWireGolden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation("missing", WithReason("missing_required_flags"))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
want := "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"missing\",\n \"reason\": \"missing_required_flags\"\n }\n}\n"
|
||||
if got := b.String(); got != want {
|
||||
t.Fatalf("legacy error wire changed\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONReasonProjectionStaysLegacy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("confirmation_required", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"confirmation required",
|
||||
WithReason("confirmation_required"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"reason": "confirmation_required"`) {
|
||||
t.Fatalf("expected confirmation_required reason, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, `"subtype"`) || strings.Contains(got, `"type"`) {
|
||||
t.Fatalf("unified fields leaked into legacy wire: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no reason omits reason", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"reason"`) {
|
||||
t.Fatalf("reason must be omitted when Reason is empty, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
|
||||
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
|
||||
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
|
||||
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
|
||||
validation := NewValidation("bad param")
|
||||
|
||||
if got := ExitCode(confirmation); got != 3 {
|
||||
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
|
||||
}
|
||||
if got := ExitCode(validation); got != 3 {
|
||||
t.Fatalf("validation ExitCode = %d, want 3", got)
|
||||
}
|
||||
// Legacy reason distinguishes the confirmation subtype.
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, confirmation); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"reason": "confirmation_required"`) {
|
||||
t.Fatalf("confirmation must carry reason, got %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := &Error{Category: CategoryPartial, Message: "partial"}
|
||||
if got := ExitCode(err); got != ExitCodeInternal {
|
||||
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
|
||||
}
|
||||
if ExitCodePartial != 7 {
|
||||
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"category": "internal"`) {
|
||||
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONKeepsLegacyCategories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"api", NewAPI("x"), "api"},
|
||||
{"auth", NewAuth("x"), "auth"},
|
||||
{"validation", NewValidation("x"), "validation"},
|
||||
{"discovery", NewDiscovery("x"), "discovery"},
|
||||
{"internal", NewInternal("x"), "internal"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, tc.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
|
||||
t.Fatalf("expected legacy category %q in %s", tc.want, got)
|
||||
}
|
||||
if strings.Contains(got, `"type"`) {
|
||||
t.Fatalf("unified type leaked into legacy JSON: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsWireStableFieldsSubset protects the legacy recovery fields that
|
||||
// remain useful without changing the top-level envelope.
|
||||
func TestErrorsWireStableFieldsSubset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"rpc failed",
|
||||
WithReason("rate_limit"),
|
||||
WithHint("h"),
|
||||
WithRetryable(true),
|
||||
WithRetryAfterSeconds(5),
|
||||
WithActions("retry"),
|
||||
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
|
||||
WithRPCCode(-32602),
|
||||
WithRPCData([]byte(`{"field":"x"}`)),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"category"`, `"reason"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
|
||||
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("wire-stable field %s missing from %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
|
||||
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
|
||||
// 未置)时 retryable 缺席。
|
||||
func TestErrorsRetryableOmitEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("true present", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retryable": true`) {
|
||||
t.Fatalf("expected retryable:true, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("unset omitted", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"retryable"`) {
|
||||
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
|
||||
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
|
||||
// type/code 组合,plain 错误归 internal(rc=5)。
|
||||
func TestErrorsCategorySnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
category string
|
||||
code string
|
||||
}{
|
||||
{"api", NewAPI("x"), "api", `"code": 1`},
|
||||
{"auth", NewAuth("x"), "auth", `"code": 2`},
|
||||
{"validation", NewValidation("x"), "validation", `"code": 3`},
|
||||
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
|
||||
{"internal", NewInternal("x"), "internal", `"code": 5`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, tc.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"category": "`+tc.category+`"`) || !strings.Contains(got, tc.code) {
|
||||
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
|
||||
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
|
||||
func TestErrorsActionsArrayPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"confirm required",
|
||||
WithReason("confirmation_required"),
|
||||
WithActions("dws chat send --yes", "", "dws chat cancel"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"actions"`,
|
||||
`"dws chat send --yes"`,
|
||||
`"dws chat cancel"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %s in actions, got %q", want, got)
|
||||
}
|
||||
}
|
||||
// 空串条目被过滤:不应出现空引号动作。
|
||||
if strings.Contains(got, `""`) {
|
||||
t.Fatalf("empty action must be filtered, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
|
||||
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
|
||||
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
|
||||
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"rpc failed",
|
||||
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
|
||||
WithRPCCode(-32602),
|
||||
WithRPCData([]byte(`{"field":"base_id"}`)),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"trace_id": "trace-abc"`,
|
||||
`"rpc_code": -32602`,
|
||||
`"field"`,
|
||||
`"base_id"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsLegacyPrintJSONOmitsUnifiedOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewInternal("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"outcome"`) {
|
||||
t.Fatalf("legacy error JSON must not carry unified outcome, got %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
|
||||
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
|
||||
// transport 重试延迟选择,B196 双通道分离)。
|
||||
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
|
||||
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
|
||||
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
|
||||
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
|
||||
// (WithRetryAfterSeconds 忽略负值)。
|
||||
func TestRetryAfterZeroValuePreserved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
|
||||
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
|
||||
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
|
||||
}
|
||||
|
||||
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
|
||||
if negative.RetryAfterSeconds != nil {
|
||||
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
|
||||
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
|
||||
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("nonzero", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
|
||||
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("zero preserved", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
|
||||
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("unset omitted", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"retry_after_seconds"`) {
|
||||
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
|
||||
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
|
||||
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
|
||||
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tz := time.FixedZone("CST", 8*60*60)
|
||||
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
|
||||
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
|
||||
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
|
||||
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
|
||||
}
|
||||
if err.NextRetryAt == nil {
|
||||
t.Fatal("NextRetryAt must be set")
|
||||
}
|
||||
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
|
||||
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
|
||||
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
|
||||
}
|
||||
|
||||
// wire 上两字段同现且互不覆盖。
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"retry_after_seconds": 30`) {
|
||||
t.Fatalf("missing retry_after_seconds:30 in %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
|
||||
t.Fatalf("missing next_retry_at in %s", got)
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,18 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hint, action := ServerGuidance(ServerDiagnostics{
|
||||
FriendlyHint: "follow the recovery action",
|
||||
ActionURL: "https://example.test/recover",
|
||||
})
|
||||
if hint != "follow the recovery action" || action != "https://example.test/recover" {
|
||||
t.Fatalf("ServerGuidance() = (%q, %q)", hint, action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, actionURL := range []string{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package errors
|
||||
|
||||
// 统一退出码表(B171/B172;轮 10 裁决⑬,权威 = 规划 v1.2 OQ-1 定案):
|
||||
// 类别与退出码一一对应,`confirmation_required` 是 `validation` 下的子类
|
||||
// 共享 3,不新增独立退出码。
|
||||
//
|
||||
// 跨包同源锁定:internal/output.ExitCodeForEnvelope 对同一信封必须给出
|
||||
// 本表完全一致的码(api=1/auth=2/validation=3/discovery=6/internal=5)。
|
||||
// Typed output.Partial remains the only path to partial_failure exit 7.
|
||||
// 修改本表 = 契约变更,必须双侧同步并更新两侧同源测试。
|
||||
var exitCodeByCategory = map[Category]int{
|
||||
CategoryAPI: ExitCodeAPI,
|
||||
CategoryAuth: ExitCodeAuth,
|
||||
CategoryValidation: ExitCodeValidation,
|
||||
CategoryDiscovery: ExitCodeDiscovery,
|
||||
CategoryInternal: ExitCodeInternal,
|
||||
CategoryPartial: ExitCodeInternal,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package errors
|
||||
|
||||
// 错误信封 wire-stable 字段集(契约规范 §2.4;B174)。
|
||||
//
|
||||
// Agent 可编程分流的字段集合:type/subtype/code/retryable/
|
||||
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data/
|
||||
// outcome。wireErrors 的每个字段都必须落在此集合内;新增字段 = 契约
|
||||
// 扩展,需评审。wireErrors 未声明 JSON tag 的字段(如 Cause)是内部
|
||||
// 字段,序列化缺席,不属于 wire。
|
||||
|
||||
// WireStableFields 是错误信封 wire-stable 字段名全集(含 outcome)。
|
||||
var WireStableFields = []string{
|
||||
"type",
|
||||
"subtype",
|
||||
"code",
|
||||
"retryable",
|
||||
"retry_after_seconds",
|
||||
"message",
|
||||
"hint",
|
||||
"actions",
|
||||
"trace_id",
|
||||
"rpc_code",
|
||||
"rpc_data",
|
||||
"outcome",
|
||||
}
|
||||
|
||||
// WireStableErrorBodyFields 是 error 对象体(不含顶层 outcome)的
|
||||
// wire-stable 字段名子集。
|
||||
var WireStableErrorBodyFields = []string{
|
||||
"type",
|
||||
"subtype",
|
||||
"code",
|
||||
"retryable",
|
||||
"retry_after_seconds",
|
||||
"message",
|
||||
"hint",
|
||||
"actions",
|
||||
"trace_id",
|
||||
"rpc_code",
|
||||
"rpc_data",
|
||||
}
|
||||
+173
-44
@@ -27,6 +27,79 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("chat", toolName, args)
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args)
|
||||
if err != nil {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) && cliErr.Operation == "" {
|
||||
withOperation := *cliErr
|
||||
withOperation.Operation = "chat/" + toolName
|
||||
return &withOperation
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewAPI("MCP read tool returned no non-empty text content",
|
||||
apperrors.WithOperation("chat/"+toolName),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithReason("empty_tool_response"),
|
||||
)
|
||||
}
|
||||
data := map[string]any{}
|
||||
if err := unmarshalJSONUseNumber(text, &data); err != nil {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
return writeCommandPayload(cmd, projectChatMessagesPayload(data, search))
|
||||
}
|
||||
|
||||
func projectChatMessagesPayload(data map[string]any, search bool) map[string]any {
|
||||
items := chatmsg.ListMessageItems(data)
|
||||
if search {
|
||||
items = chatmsg.SearchItems(data)
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
projected := make(map[string]any, len(item)+8)
|
||||
for key, value := range item {
|
||||
projected[key] = value
|
||||
}
|
||||
for key, value := range chatmsg.ProjectMessageV1(item, true) {
|
||||
if key == "messageId" || key == "text" {
|
||||
projected[key] = value
|
||||
continue
|
||||
}
|
||||
if _, exists := projected[key]; !exists {
|
||||
projected[key] = value
|
||||
}
|
||||
}
|
||||
if value, exists := item["openMessageId"]; exists {
|
||||
projected["openMessageId"] = value
|
||||
} else if value, exists := projected["messageId"]; exists {
|
||||
projected["openMessageId"] = value
|
||||
}
|
||||
if value, exists := item["content"]; exists {
|
||||
projected["content"] = value
|
||||
} else if value, exists := projected["text"]; exists {
|
||||
projected["content"] = value
|
||||
}
|
||||
messages = append(messages, projected)
|
||||
}
|
||||
|
||||
payload := make(map[string]any, len(data)+1)
|
||||
for key, value := range data {
|
||||
payload[key] = value
|
||||
}
|
||||
payload["messages"] = messages
|
||||
return payload
|
||||
}
|
||||
|
||||
func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) {
|
||||
forwardStr, _ := cmd.Flags().GetString("forward")
|
||||
forward := forwardStr != "false"
|
||||
@@ -137,13 +210,14 @@ func runConversationScopedPagedMessageSearch(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runConversationScopedMessageSearch(
|
||||
return runConversationScopedMessageSearchWithProjector(
|
||||
cmd,
|
||||
cfg.ServerID,
|
||||
cfg.ToolName,
|
||||
scopeParam,
|
||||
toolArgs,
|
||||
conversationIDs,
|
||||
cfg.ProjectResult,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +226,24 @@ func runConversationScopedMessageSearch(
|
||||
serverID, toolName, scopeParam string,
|
||||
toolArgs map[string]any,
|
||||
conversationIDs []string,
|
||||
) error {
|
||||
return runConversationScopedMessageSearchWithProjector(
|
||||
cmd,
|
||||
serverID,
|
||||
toolName,
|
||||
scopeParam,
|
||||
toolArgs,
|
||||
conversationIDs,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func runConversationScopedMessageSearchWithProjector(
|
||||
cmd *cobra.Command,
|
||||
serverID, toolName, scopeParam string,
|
||||
toolArgs map[string]any,
|
||||
conversationIDs []string,
|
||||
projectResult func(map[string]any) map[string]any,
|
||||
) error {
|
||||
conversationIDs = uniqueNonEmptyStrings(conversationIDs)
|
||||
if len(conversationIDs) == 0 {
|
||||
@@ -303,6 +395,9 @@ func runConversationScopedMessageSearch(
|
||||
}
|
||||
payload["paging"] = paging
|
||||
}
|
||||
if projectResult != nil {
|
||||
payload = projectResult(payload)
|
||||
}
|
||||
return writeCommandPayload(cmd, payload)
|
||||
}
|
||||
|
||||
@@ -670,6 +765,17 @@ func pagedChatConversationMessagesConfig(toolName string, build func(*cobra.Comm
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pagedProjectedChatSearchConfig(cmd *cobra.Command, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
|
||||
cfg := pagedChatConversationMessagesConfig(toolName, build)
|
||||
cfg.Fallback = func(args map[string]any) error {
|
||||
return callProjectedChatMessages(cmd, toolName, args, true)
|
||||
}
|
||||
cfg.ProjectResult = func(payload map[string]any) map[string]any {
|
||||
return projectChatMessagesPayload(payload, true)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pagedChatConversationMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
|
||||
cfg := pagedChatConversationMessagesConfig(toolName, build)
|
||||
cfg.ServerID = serverID
|
||||
@@ -1492,6 +1598,36 @@ func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
func buildChatGroupShareInviteArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlags(cmd, "source"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return nil, fmt.Errorf("--target or --receiver is required")
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return nil, fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
|
||||
}
|
||||
if target != "" {
|
||||
toolArgs["targetOpenConversationId"] = target
|
||||
}
|
||||
if receiver != "" {
|
||||
toolArgs["receiverOpenDingTalkId"] = receiver
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
|
||||
toolArgs["expiresSeconds"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
func appendChatChmodParams(cmd *cobra.Command, toolArgs map[string]any) error {
|
||||
conversationID, _ := cmd.Flags().GetString("conversation-id")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
@@ -1974,11 +2110,11 @@ func newChatCommand() *cobra.Command {
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "grantParams.openCid", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
|
||||
{Name: "open-dingtalk-id", Property: "grantParams.openDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "permParam", Property: "grantParams", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
|
||||
{Name: "user", Property: "grantParams.userId", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
@@ -2017,10 +2153,15 @@ func newChatCommand() *cobra.Command {
|
||||
chatDataAuthCrossOrgCmd.Flags().String("grant-type", "timed", "授权策略: once|session|timed|permanent")
|
||||
chatDataAuthCrossOrgCmd.Flags().String("ttl", "24h", "timed 授权有效期,如 1h/4h/24h/7d")
|
||||
chatDataAuthCrossOrgCmd.Flags().String("session-id", "", "session 授权的会话标识")
|
||||
chatDataAuthCrossOrgCmd.Flags().BoolP("yes", "y", false, "确认执行跨组织 chat 数据授权")
|
||||
DeclareLeafMetadata(chatDataAuthCrossOrgCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := buildChatCrossOrgDataAuthArgs(cmd)
|
||||
return err
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2045,10 +2186,10 @@ func newChatCommand() *cobra.Command {
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
|
||||
{Name: "all", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
|
||||
{Name: "target-org-id", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2464,11 +2605,12 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "拉取会话消息内容",
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}'
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询 userId: dws contact user search --query "姓名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -2512,7 +2654,7 @@ func newChatCommand() *cobra.Command {
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
return callMCPTool("list_conversation_message_v2", toolArgs)
|
||||
return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"time": timeVal,
|
||||
@@ -2526,7 +2668,7 @@ func newChatCommand() *cobra.Command {
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
return callMCPTool("list_individual_chat_message", toolArgs)
|
||||
return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(chatMessageListCmd, LeafSpec{
|
||||
@@ -2552,7 +2694,10 @@ func newChatCommand() *cobra.Command {
|
||||
AgentSummary: "分页读取指定会话消息及其引用上下文",
|
||||
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
|
||||
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
|
||||
Examples: []string{"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"},
|
||||
Examples: []string{
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "direction", Property: "forward"},
|
||||
@@ -3625,17 +3770,18 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "按关键词搜索消息",
|
||||
Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
|
||||
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
|
||||
dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}'
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
return runConversationScopedPagedMessageSearch(
|
||||
cmd,
|
||||
pagedChatConversationMessagesConfig("search_messages_by_keyword", chatMessageSearchArgs),
|
||||
pagedProjectedChatSearchConfig(cmd, "search_messages_by_keyword", chatMessageSearchArgs),
|
||||
"openConversationId",
|
||||
[]string{groupID},
|
||||
)
|
||||
@@ -3666,7 +3812,7 @@ func newChatCommand() *cobra.Command {
|
||||
AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"},
|
||||
Examples: []string{
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"",
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300",
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300 --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
Parameters: append([]contract.ParamDecl{
|
||||
@@ -8227,12 +8373,16 @@ status 可选值:
|
||||
if err != nil {
|
||||
return fmt.Errorf("--record-id must be a valid integer: %w", err)
|
||||
}
|
||||
status := mustGetFlag(cmd, "status")
|
||||
if status != "AuditApprove" && status != "AuditDelete" {
|
||||
return fmt.Errorf("unsupported audit status %q, must be one of: AuditApprove, AuditDelete", status)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"applyRecordId": recordID,
|
||||
"applicantUid": mustGetFlag(cmd, "applicant"),
|
||||
"inviterUid": mustGetFlag(cmd, "inviter"),
|
||||
"status": mustGetFlag(cmd, "status"),
|
||||
"status": status,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
toolArgs["auditDescription"] = v
|
||||
@@ -8282,7 +8432,7 @@ status 可选值:
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "inviter", Property: "inviterUid", Required: boolPtr(true)},
|
||||
{Name: "record-id", Property: "applyRecordId", Required: boolPtr(true), InterfaceType: "integer"},
|
||||
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete", "AuditIgnore", "AuditRefuse", "AuditBlock"}},
|
||||
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -9386,32 +9536,10 @@ status 可选值:
|
||||
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "source"); err != nil {
|
||||
toolArgs, err := buildChatGroupShareInviteArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return fmt.Errorf("--target or --receiver is required")
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
|
||||
}
|
||||
if target != "" {
|
||||
toolArgs["targetOpenConversationId"] = target
|
||||
}
|
||||
if receiver != "" {
|
||||
toolArgs["receiverOpenDingTalkId"] = receiver
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
|
||||
toolArgs["expiresSeconds"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
return callMCPToolOnServer("im", "share_group_invite_url", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -9421,10 +9549,11 @@ status 可选值:
|
||||
chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)")
|
||||
chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值")
|
||||
chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)")
|
||||
chatGroupShareInviteCmd.Flags().BoolP("yes", "y", false, "确认分享群邀请链接")
|
||||
DeclareLeafMetadata(chatGroupShareInviteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
server: "chat",
|
||||
tool: "search_messages_by_keyword",
|
||||
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
@@ -219,6 +219,21 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
if len(items) != 1 || len(messages) != 2 {
|
||||
t.Fatalf("conversation items = %#v", items)
|
||||
}
|
||||
if tt.name == "search" {
|
||||
projected, ok := got["messages"].([]any)
|
||||
if !ok || len(projected) != 2 {
|
||||
t.Fatalf("projected messages = %#v", got["messages"])
|
||||
}
|
||||
for i, wantID := range []string{"m1", "m2"} {
|
||||
message, ok := projected[i].(map[string]any)
|
||||
if !ok || message["messageId"] != wantID || message["openMessageId"] != wantID {
|
||||
t.Fatalf("projected message %d = %#v", i, projected[i])
|
||||
}
|
||||
if _, exists := message["text"]; !exists {
|
||||
t.Fatalf("projected message %d missing text: %#v", i, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(items) != 2 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
|
||||
@@ -701,9 +701,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
installExampleGlobalFlags(cmd)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
@@ -746,6 +744,22 @@ func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAuditRejectsUnsupportedStatus(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditRefuse")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported audit status error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unsupported audit status "AuditRefuse"`) {
|
||||
t.Fatalf("error = %v, want unsupported status", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unsupported status must not call MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{resolveUsers: true}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -14,14 +14,59 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// resolveCommandFormat 是 helpers 侧的 format 解析桥接(B44,WS1 改动点4):
|
||||
// 从 cmd flags 统一解析输出 format——显式非空 --format 恒优先,其次 --json
|
||||
// 布尔简写,两者皆无落 fallback json(轮11-DEV 裁决函数
|
||||
// output.ResolveFormatWithJSONShorthand,含未知值归一化降级)。与主漏斗
|
||||
// callMCPToolInternalOpts 的 output.ParseFormat(caller 字符串路径,轮8 A3)
|
||||
// 共用同一 normalizeFormat 归一化规则——cmd 路径与 caller 路径收敛于同一
|
||||
// 单一事实源,不重造第二套解析。nil cmd 返回 fallback(不 panic)。
|
||||
func resolveCommandFormat(cmd *cobra.Command) output.Format {
|
||||
return output.ResolveFormatWithJSONShorthand(cmd, output.FormatJSON)
|
||||
}
|
||||
|
||||
// writeCommandPayload 按当前 format 分发命令载荷(B57,WS1 改动点4):
|
||||
// format 经 resolveCommandFormat(B44 桥接)解析,替换原固定 FormatJSON 兜底;
|
||||
// --fields/--jq 全局过滤同路联动;数据出口走 cmd.OutOrStdout()(可被测试
|
||||
// 重定向,不硬编码 os.Stdout)。nil cmd 按 json 渲染进 io.Discard(与
|
||||
// output.WriteCommandPayload 的容错口径一致)。
|
||||
func writeCommandPayload(cmd *cobra.Command, payload any) error {
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
format := resolveCommandFormat(cmd)
|
||||
if cmd == nil {
|
||||
return output.Write(io.Discard, format, payload)
|
||||
}
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), format, payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
|
||||
}
|
||||
|
||||
// writeEnvelope 是 helpers 侧的统一信封装配出口(B58,WS1 改动点2):信封
|
||||
// 渲染先进 buffer 再写 cmd 流(buffer-first 由 internal/output 承载),按
|
||||
// outcome 分流:
|
||||
//
|
||||
// - success / pending / partial_failure → 数据通道:经 output.WriteEnvelope
|
||||
// 走完整 format 分发矩阵(含未知 format 降级 + stderr warning,AC-09);
|
||||
// - failure(含 nil 信封降级)→ 错误通道:经 Emitter 落 cmd.ErrOrStderr(),
|
||||
// stdout 严格零字节(AC-11),失败信封恒完整 JSON 且绕过 format/jq/fields
|
||||
// (轮8裁决⑪)。
|
||||
//
|
||||
// 本函数只做装配分流,不重定义信封类型或渲染规则。
|
||||
func writeEnvelope(cmd *cobra.Command, env *output.Envelope) error {
|
||||
if env != nil && env.Outcome != output.OutcomeFailure {
|
||||
return output.WriteEnvelope(cmd, env, output.FormatJSON)
|
||||
}
|
||||
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||
if cmd != nil {
|
||||
w = cmd.OutOrStdout()
|
||||
errW = cmd.ErrOrStderr()
|
||||
}
|
||||
return output.NewEmitter(w, errW, output.FormatJSON, "", "").Emit(env)
|
||||
}
|
||||
|
||||
func preferLegacyLeaf(cmd *cobra.Command) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -334,11 +335,22 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
// Release the child so the parent can exit without leaving a zombie.
|
||||
pid := child.Process.Pid
|
||||
_ = child.Process.Release()
|
||||
|
||||
// The foreground/daemon root remains one streaming legacy command in this
|
||||
// release. It cannot adopt a terminal result until the modes are split.
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectDaemonStartedResult 是 `dev connect --daemon` 父进程的结果 DTO
|
||||
// (队列 B110):pid/日志路径等机器事实进成功信封的 data 层。
|
||||
type connectDaemonStartedResult struct {
|
||||
Status string `json:"status"`
|
||||
Pid int `json:"pid"`
|
||||
LogPath string `json:"logPath,omitempty"`
|
||||
DirKey string `json:"dirKey,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
}
|
||||
|
||||
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
|
||||
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
|
||||
fmt.Fprintf(w, " logs: %s\n", logPath)
|
||||
@@ -587,21 +599,32 @@ func supervisedLabel(supervised bool) string {
|
||||
return "none (foreground or external)"
|
||||
}
|
||||
|
||||
// connectStopResult 是 `dev connect stop` 的结果 DTO(统一输出 dev 域试点,
|
||||
// 队列 B112):进成功信封的 data 层。status 是机器可分支的终态枚举。
|
||||
type connectStopResult struct {
|
||||
Status string `json:"status"`
|
||||
PreviewKind string `json:"preview_kind,omitempty"`
|
||||
Pid int `json:"pid,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
|
||||
// forwards to the worker, which releases the lock and Stream connection), poll
|
||||
// until it exits, escalate to SIGKILL on timeout, and clean up the pid file.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
// 人读进度行写 w(调用方传 stderr);机器可消费的结果作为返回值由 RunE 层
|
||||
// 包进统一信封写 stdout(契约规范 §5.1:stdout 只承载数据)。
|
||||
func daemonStopResult(w io.Writer, dirKey string) (*connectStopResult, error) {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
return nil, apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
return nil, apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil || st.Pid <= 0 {
|
||||
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
|
||||
return nil
|
||||
return &connectStopResult{Status: "not_running", Detail: "no daemon record found"}, nil
|
||||
}
|
||||
if !daemonProcessAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
@@ -625,17 +648,17 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "orphan_worker_stopped", Pid: hb.Pid, Detail: "supervisor was dead; orphan worker stopped"}, nil
|
||||
}
|
||||
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "cleaned_stale", Pid: st.Pid, Detail: "cleaned up stale pid file"}, nil
|
||||
}
|
||||
proc, err := daemonFindProcess(st.Pid)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
}
|
||||
if err := daemonSignalProcess(proc, syscall.SIGTERM); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
}
|
||||
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
|
||||
|
||||
@@ -644,7 +667,7 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
if !daemonProcessAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "stopped", Pid: st.Pid}, nil
|
||||
}
|
||||
helperSleep(200 * time.Millisecond)
|
||||
}
|
||||
@@ -653,7 +676,15 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
helperSleep(200 * time.Millisecond)
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "force_killed", Pid: st.Pid, Detail: "graceful stop timed out; SIGKILL sent"}, nil
|
||||
}
|
||||
|
||||
// daemonStop keeps the established helper contract for legacy callers and
|
||||
// platform coverage. Unified-result commands use daemonStopResult when they
|
||||
// need the typed terminal payload.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
_, err := daemonStopResult(w, dirKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStatusCommand implements `dws devapp robot connect
|
||||
@@ -680,6 +711,9 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// Preserve the published Schema safety tuple during the unified-result
|
||||
// pilot. Correcting this historical classification is a separate,
|
||||
// explicitly reviewed interface change.
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
@@ -692,6 +726,10 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
PrimaryCLIPath: "dev connect status",
|
||||
},
|
||||
Description: "查看后台连接器守护进程状态",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"本地连接器守护进程状态","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"},"lastError":{"type":"string","description":"最近一次连接错误;健康时可能缺省"}},"required":["state","supervised"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
@@ -722,17 +760,40 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStop(cmd.OutOrStdout(), dirKey)
|
||||
if commandDryRun(cmd) {
|
||||
preview := &connectStopResult{
|
||||
Status: "preview",
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
Detail: "would send SIGTERM to the selected daemon and escalate to SIGKILL only after the graceful timeout",
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
|
||||
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
|
||||
}
|
||||
result, err := daemonStopResult(cmd.ErrOrStderr(), dirKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 统一输出 dev 域试点(队列 B112):结果信封化(ok:true + data)。
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
|
||||
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{
|
||||
// Preserve the published Schema safety tuple during the unified-result
|
||||
// pilot. The stricter classification belongs in a separate interface
|
||||
// migration rather than this framework-only compatibility rollout.
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(c *cobra.Command, _ []string) error {
|
||||
_, err := connectDaemonDirKeyFromFlags(c)
|
||||
return err
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev",
|
||||
@@ -742,6 +803,7 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
PrimaryCLIPath: "dev connect stop",
|
||||
},
|
||||
Description: "优雅停止后台连接器守护进程",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
@@ -751,13 +813,66 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
AgentSummary: "优雅停止后台连接器守护进程",
|
||||
UseWhen: []string{"用户明确要求停止本地 Stream/连接器守护进程"},
|
||||
AvoidWhen: []string{"只想查看状态时用 dev connect status"},
|
||||
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>"},
|
||||
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>", "dws dev connect stop --unified-app-id <unifiedAppId> --dry-run --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// connectRestartResult 是 `dev connect restart` 的结果 DTO(队列 B113):
|
||||
// 进成功信封的 data 层。pid 是重启后新守护进程的 pid(从刷新后的
|
||||
// daemon-state.json 回读;子进程自身的结果信封由本命令信封取代,不转发)。
|
||||
type connectRestartResult struct {
|
||||
Status string `json:"status"`
|
||||
PreviewKind string `json:"preview_kind,omitempty"`
|
||||
Pid int `json:"pid,omitempty"`
|
||||
DirKey string `json:"dirKey,omitempty"`
|
||||
UnifiedAppID string `json:"unifiedAppId,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
func validateConnectRestart(cmd *cobra.Command) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// An explicit unified app ID is sufficient to produce a side-effect-free
|
||||
// restart plan. Runtime execution still requires persisted daemon state.
|
||||
if commandDryRun(cmd) && devAppStringFlag(cmd, "unified-app-id") != "" {
|
||||
return nil
|
||||
}
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil {
|
||||
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
|
||||
}
|
||||
if st.UnifiedAppID == "" {
|
||||
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConnectRestartPreview(cmd *cobra.Command, dirKey, unifiedAppID, channel string) error {
|
||||
preview := &connectRestartResult{
|
||||
Status: "preview",
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
DirKey: dirKey,
|
||||
UnifiedAppID: unifiedAppID,
|
||||
Channel: channel,
|
||||
Command: fmt.Sprintf("dws dev connect --daemon --unified-app-id %s", unifiedAppID),
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
|
||||
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectRestartCommand implements `dws devapp robot connect
|
||||
// restart`: stop the running daemon (if any) then re-launch it using the
|
||||
// persisted unifiedAppId so credentials are freshly fetched from the dev
|
||||
@@ -773,6 +888,7 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
explicitUnifiedAppID := devAppStringFlag(cmd, "unified-app-id")
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
@@ -781,6 +897,9 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil && commandDryRun(cmd) && explicitUnifiedAppID != "" {
|
||||
return writeConnectRestartPreview(cmd, dirKey, explicitUnifiedAppID, "")
|
||||
}
|
||||
if st == nil {
|
||||
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
|
||||
}
|
||||
@@ -788,10 +907,15 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if unifiedAppID == "" {
|
||||
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
return writeConnectRestartPreview(cmd, dirKey, unifiedAppID, st.Channel)
|
||||
}
|
||||
// Stop the running daemon first (ignore "not running" — that's fine).
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "stopping existing daemon...")
|
||||
if err := daemonStop(cmd.OutOrStdout(), dirKey); err != nil {
|
||||
fmt.Fprintf(cmd.OutOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
|
||||
// 统一输出 dev 域试点(队列 B113):stopping/restarting 进度行改走
|
||||
// stderr,stdout 只承载最终结果信封(契约规范 §5.1)。
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "stopping existing daemon...")
|
||||
if err := daemonStop(cmd.ErrOrStderr(), dirKey); err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
|
||||
}
|
||||
// Re-exec dws dev connect --daemon with the stored flags. An explicit
|
||||
// --profile on this invocation overrides the persisted one.
|
||||
@@ -816,24 +940,51 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if st.AlwaysOn {
|
||||
args = append(args, "--alwayson")
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "restarting connector: dws %s\n", strings.Join(args, " "))
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "restarting connector: dws %s\n", strings.Join(args, " "))
|
||||
// Run synchronously: `--daemon` itself detaches the supervisor and
|
||||
// returns quickly, so waiting here costs nothing and lets a failed
|
||||
// relaunch (e.g. credential fetch error) surface as a non-zero exit
|
||||
// instead of a silent success.
|
||||
// instead of a silent success. 子进程自己的输出(含它自己的结果
|
||||
// 信封)不再转发到本命令的 stdout——由本命令的信封统一承载结果,
|
||||
// 避免两个 JSON 文档拼接破坏 `| jq`(契约规范 §5.1)。
|
||||
restartCmd := daemonCommand(exe, args...)
|
||||
restartCmd.Stdout = cmd.OutOrStdout()
|
||||
restartCmd.Stderr = cmd.OutOrStderr()
|
||||
restartCmd.Stdout = cmd.ErrOrStderr()
|
||||
restartCmd.Stderr = cmd.ErrOrStderr()
|
||||
restartCmd.Stdin = nil
|
||||
if err := restartCmd.Run(); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("重启失败(旧守护进程已停止,连接器记录已清除);恢复请手动执行: dws %s", strings.Join(args, " ")))
|
||||
}
|
||||
return nil
|
||||
// 回读刷新后的 daemon-state.json 拿新 supervisor pid(restart 是同步
|
||||
// re-exec,子进程落盘早于返回;读不到则 pid 缺席,status 仍诚实)。
|
||||
result := &connectRestartResult{
|
||||
Status: "restarted",
|
||||
DirKey: dirKey,
|
||||
UnifiedAppID: unifiedAppID,
|
||||
Channel: st.Channel,
|
||||
Command: "dws " + strings.Join(args, " "),
|
||||
}
|
||||
if fresh, rerr := readDaemonState(dir); rerr == nil && fresh != nil {
|
||||
result.Pid = fresh.Pid
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
|
||||
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "not_required", Idempotency: "unknown"},
|
||||
Validate: func(c *cobra.Command, _ []string) error { return validateConnectRestart(c) },
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_restart", CanonicalPath: "dev.connect_restart", CLIPath: "dev connect restart", PrimaryCLIPath: "dev connect restart"},
|
||||
Description: "使用持久化配置重启本地连接器守护进程",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地守护进程管理与远端凭证重新获取,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "重启本地连接器守护进程", UseWhen: []string{"连接器需要恢复或重载配置"}, AvoidWhen: []string{"仅查看状态时使用 dev connect status"}, Examples: []string{"dws dev connect restart --unified-app-id <unifiedAppId>", "dws dev connect restart --unified-app-id <unifiedAppId> --dry-run --format json"}},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -855,6 +1006,9 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
|
||||
resolveAppNames(cmd, runner, reports)
|
||||
w := cmd.OutOrStdout()
|
||||
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
|
||||
if reports == nil {
|
||||
reports = []connectHealthReport{}
|
||||
}
|
||||
data, _ := json.MarshalIndent(reports, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
return nil
|
||||
@@ -868,6 +1022,19 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Bool("json", false, "以 JSON 数组输出(供脚本消费)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_list", CanonicalPath: "dev.connect_list", CLIPath: "dev connect list", PrimaryCLIPath: "dev connect list"},
|
||||
Description: "列出本机连接器及其健康状态",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"array","description":"本机连接器及其健康状态列表","items":{"type":"object","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"}},"required":["state","supervised"],"additionalProperties":true}}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地连接器状态与可选远端应用名称解析,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "列出本机全部连接器及健康状态", UseWhen: []string{"需要查看本机连接器清单"}, AvoidWhen: []string{"只检查一个连接器时使用 dev connect status"}, Examples: []string{"dws dev connect list --json"}},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -164,13 +165,18 @@ func TestCrossPlatformCoverageStartDaemonLifecycleEdges(t *testing.T) {
|
||||
fixture := writeShellExecutable(t, t.TempDir(), "daemon-success", "exit 0\n")
|
||||
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(fixture) }
|
||||
cmd := daemonTestCommand()
|
||||
var out bytes.Buffer
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
if err := startDaemon(cmd, "key", "client", "app", "custom", "staff", "profile", true); err != nil {
|
||||
t.Fatalf("startDaemon() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "daemon started") {
|
||||
t.Fatalf("start output = %q", out.String())
|
||||
// The streaming root remains legacy until a dedicated stream contract.
|
||||
if !strings.Contains(out.String(), "daemon started") || !strings.Contains(out.String(), "pid") {
|
||||
t.Fatalf("legacy daemon start output = %q", out.String())
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("legacy daemon start stderr = %q", errOut.String())
|
||||
}
|
||||
|
||||
// startDaemon intentionally releases its detached child. On Windows the
|
||||
@@ -564,7 +570,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
if err := daemonStop(&bytes.Buffer{}, "key"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "key"); err == nil {
|
||||
t.Fatal("stop with blocked directory succeeded")
|
||||
}
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
@@ -572,7 +578,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := daemonStop(&bytes.Buffer{}, "corrupt"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "corrupt"); err == nil {
|
||||
t.Fatal("stop with corrupt state succeeded")
|
||||
}
|
||||
})
|
||||
@@ -586,7 +592,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
}
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
||||
if err := daemonStop(&bytes.Buffer{}, "find-error"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "find-error"); err == nil {
|
||||
t.Fatal("find process error was ignored")
|
||||
}
|
||||
})
|
||||
@@ -634,7 +640,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
}
|
||||
sleeps := 0
|
||||
helperSleep = func(time.Duration) { sleeps++ }
|
||||
if err := daemonStop(&bytes.Buffer{}, "orphan-graceful"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-graceful"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 1 || signals[0] != syscall.SIGTERM || sleeps != 1 {
|
||||
@@ -660,7 +666,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
helperSleep = func(time.Duration) {}
|
||||
if err := daemonStop(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
||||
@@ -678,7 +684,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
base := time.Now()
|
||||
daemonNow = func() time.Time { return base }
|
||||
daemonSignalProcess = func(*os.Process, os.Signal) error { return nil }
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -687,7 +693,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
prepare(t, "live-signal-error-hooked", false)
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonSignalProcess = func(*os.Process, os.Signal) error { return errors.New("signal") }
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
|
||||
t.Fatal("signal error was ignored")
|
||||
}
|
||||
})
|
||||
@@ -710,7 +716,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
helperSleep = func(time.Duration) {}
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-force-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-force-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
||||
@@ -763,25 +769,25 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
|
||||
}
|
||||
resolveAppNames(cmd, connectResponseRunner{err: errors.New("offline")}, []connectHealthReport{{UnifiedAppID: "u-1"}})
|
||||
|
||||
list := newDevAppRobotConnectListCommand(runner)
|
||||
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
var out bytes.Buffer
|
||||
list.SetOut(&out)
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "no connectors") {
|
||||
t.Fatalf("empty list = %q, %v", out.String(), err)
|
||||
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "no connectors found" {
|
||||
t.Fatalf("empty list output = %q, %v", out.String(), err)
|
||||
}
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
out.Reset()
|
||||
list.SetOut(&out)
|
||||
list.SetArgs([]string{"--json"})
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "null") {
|
||||
t.Fatalf("json list = %q, %v", out.String(), err)
|
||||
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
||||
t.Fatalf("json list array = %q, %v", out.String(), err)
|
||||
}
|
||||
|
||||
seedHeartbeat(t, "listed", connectHeartbeat{
|
||||
Pid: os.Getpid(), ClientID: strings.Repeat("c", 80), Channel: strings.Repeat("x", 80),
|
||||
StartUnix: time.Now().Add(-time.Minute).Unix(), ConnectedUnix: time.Now().Add(-time.Minute).Unix(),
|
||||
})
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
out.Reset()
|
||||
list.SetOut(&out)
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "STATE") {
|
||||
@@ -791,7 +797,7 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
|
||||
connectHealthReadDir = func(string) ([]os.DirEntry, error) {
|
||||
return nil, errors.New("read directory")
|
||||
}
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
list.SetOut(&bytes.Buffer{})
|
||||
if err := list.Execute(); err == nil {
|
||||
t.Fatal("list with blocked directory succeeded")
|
||||
@@ -806,6 +812,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
defaultFindProcess := daemonFindProcess
|
||||
|
||||
for _, command := range []*cobra.Command{newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStopCommand(), newDevAppRobotConnectRestartCommand()} {
|
||||
command = prepareUnifiedTestCommand(command)
|
||||
command.SetArgs(nil)
|
||||
command.SetOut(&bytes.Buffer{})
|
||||
command.SetErr(&bytes.Buffer{})
|
||||
@@ -814,20 +821,20 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
status := newDevAppRobotConnectStatusCommand()
|
||||
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
|
||||
status.SetArgs([]string{"--robot-client-id", "missing", "--json"})
|
||||
status.SetOut(&bytes.Buffer{})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("status command = %v", err)
|
||||
}
|
||||
stop := newDevAppRobotConnectStopCommand()
|
||||
stop.SetArgs([]string{"--unified-app-id", "missing"})
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
stop.SetArgs([]string{"--unified-app-id", "missing", "--yes"})
|
||||
stop.SetOut(&bytes.Buffer{})
|
||||
if err := stop.Execute(); err != nil {
|
||||
t.Fatalf("stop command = %v", err)
|
||||
}
|
||||
|
||||
restart := newDevAppRobotConnectRestartCommand()
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "missing"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -840,7 +847,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "blocked"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -852,7 +859,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
if err := os.WriteFile(daemonStatePath(corruptDir), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "corrupt-restart"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -879,8 +886,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
|
||||
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
if err := restart.Execute(); err != nil {
|
||||
@@ -892,8 +899,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
if err := restart.Execute(); err != nil {
|
||||
@@ -906,10 +913,19 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(cmd)
|
||||
return err
|
||||
}
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
root.AddCommand(restart)
|
||||
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override"})
|
||||
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override", "--yes"})
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
if err := root.Execute(); err != nil {
|
||||
@@ -941,3 +957,50 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal("failing restart subprocess succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkConnectControlDryRunPlansAndLegacyListRollback(t *testing.T) {
|
||||
preserveDaemonHooks(t)
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
stop.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var stopOut bytes.Buffer
|
||||
stop.SetOut(&stopOut)
|
||||
if err := stop.Execute(); err != nil || !strings.Contains(stopOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("stop preview=%q err=%v", stopOut.String(), err)
|
||||
}
|
||||
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var restartOut bytes.Buffer
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
dir, err := connectDaemonDir("saved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeDaemonState(dir, daemonState{DirKey: "saved", UnifiedAppID: "saved-app", Channel: "custom"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "saved", "--dry-run"})
|
||||
restartOut.Reset()
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), "saved-app") {
|
||||
t.Fatalf("saved restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(connectResponseRunner{response: map[string]any{"items": []any{}, "hasMore": false}}))
|
||||
output.SetCommandRollout(list, output.RolloutLegacyOnly)
|
||||
for _, format := range []string{"json", "table", "pretty"} {
|
||||
var out bytes.Buffer
|
||||
list.SetOut(&out)
|
||||
list.SetArgs([]string{"--format", format})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("legacy list %s: %v", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestConnectStopAndRestartDoNotRequireConfirmation(t *testing.T) {
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newDevAppRobotConnectStopCommand(),
|
||||
newDevAppRobotConnectRestartCommand(),
|
||||
} {
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Safety == nil {
|
||||
t.Fatalf("%s missing final safety declaration", cmd.Name())
|
||||
}
|
||||
if got := final.Safety.Confirmation; got != "not_required" {
|
||||
t.Fatalf("%s confirmation = %q, want not_required", cmd.Name(), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectDaemonFamilyMissingDaemonErrorPaths 是队列 B116 的「daemon 不存在
|
||||
// 错误路径」分支:对不存在的守护进程,status/stop 不得把它当成失败(空态是合法
|
||||
// 载荷,AC-06——status 保持已发布的顶层 JSON 并如实标注 not_running),restart 因无法安全
|
||||
// 重建而报 validation 错误,缺定位标识则统一报 validation(错误路径继续走
|
||||
// apperrors 通道,不进信封)。命令级端到端,stdout/stderr 分流断言。
|
||||
func TestConnectDaemonFamilyMissingDaemonErrorPaths(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
// status:daemon 不存在 → 兼容顶层 JSON,state=not_running(非错误)。
|
||||
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
|
||||
var statusOut, statusErr bytes.Buffer
|
||||
status.SetOut(&statusOut)
|
||||
status.SetErr(&statusErr)
|
||||
status.SetArgs([]string{"--robot-client-id", "ghost", "--json"})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("status on missing daemon must not error, got %v\nstderr:\n%s", err, statusErr.String())
|
||||
}
|
||||
var statusReport connectHealthReport
|
||||
if err := json.Unmarshal(statusOut.Bytes(), &statusReport); err != nil {
|
||||
t.Fatalf("status output is not the published top-level JSON: %v\n%s", err, statusOut.String())
|
||||
}
|
||||
if statusReport.State != healthNotRunning {
|
||||
t.Fatalf("status state = %q, want %q: %s", statusReport.State, healthNotRunning, statusOut.String())
|
||||
}
|
||||
if strings.Contains(statusOut.String(), `"outcome"`) || strings.Contains(statusOut.String(), `"data"`) {
|
||||
t.Fatalf("status compatibility JSON was enveloped: %s", statusOut.String())
|
||||
}
|
||||
|
||||
// stop:daemon 不存在 → ok:true 信封,data.status=not_running;人读行走 stderr。
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
var stopOut, stopErr bytes.Buffer
|
||||
stop.SetOut(&stopOut)
|
||||
stop.SetErr(&stopErr)
|
||||
stop.SetArgs([]string{"--unified-app-id", "ghost"})
|
||||
if err := stop.Execute(); err != nil {
|
||||
t.Fatalf("stop on missing daemon must not error, got %v\nstderr:\n%s", err, stopErr.String())
|
||||
}
|
||||
stopEnv := decodePhaseFEnvelope(t, stopOut.Bytes())
|
||||
if !stopEnv.OK || stopEnv.Outcome != "success" {
|
||||
t.Fatalf("stop envelope ok/outcome = %v/%q, want true/success: %s",
|
||||
stopEnv.OK, stopEnv.Outcome, stopOut.String())
|
||||
}
|
||||
if st, _ := stopEnv.Data["status"].(string); st != "not_running" {
|
||||
t.Fatalf("stop data.status = %q, want not_running: %s", st, stopOut.String())
|
||||
}
|
||||
if !strings.Contains(stopErr.String(), "not running") {
|
||||
t.Fatalf("stop human-readable line missing from stderr: %q", stopErr.String())
|
||||
}
|
||||
|
||||
// restart:daemon 不存在 → validation 错误(无法安全重建),非信封。
|
||||
// SilenceUsage 对齐生产根命令(internal/app/root.go:SilenceUsage=true),
|
||||
// 否则单叶子 Execute() 报错时 Cobra 默认把 usage 打 stdout,污染断言。
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SilenceUsage = true
|
||||
var restartOut, restartErr bytes.Buffer
|
||||
restart.SetOut(&restartOut)
|
||||
restart.SetErr(&restartErr)
|
||||
restart.SetArgs([]string{"--robot-client-id", "ghost"})
|
||||
err := restart.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "未找到连接器记录") {
|
||||
t.Fatalf("restart on missing daemon error = %v, want 未找到连接器记录", err)
|
||||
}
|
||||
if restartOut.Len() != 0 {
|
||||
t.Fatalf("restart error path must keep stdout empty, got %q", restartOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectDaemonFamilyRequiresLocatorIdentity 是队列 B116 的配套断言:
|
||||
// status/stop/restart 无定位标识(--robot-client-id / --unified-app-id 均缺)
|
||||
// 时统一报 validation 错误,错误路径不进信封、stdout 零字节。
|
||||
func TestConnectDaemonFamilyRequiresLocatorIdentity(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
for _, build := range []func() *cobra.Command{
|
||||
func() *cobra.Command { return newDevAppRobotConnectStatusCommand() },
|
||||
func() *cobra.Command { return newDevAppRobotConnectStopCommand() },
|
||||
func() *cobra.Command { return newDevAppRobotConnectRestartCommand() },
|
||||
} {
|
||||
cmd := prepareUnifiedTestCommand(build())
|
||||
cmd.SilenceUsage = true
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "需要 --robot-client-id 或 --unified-app-id") {
|
||||
t.Fatalf("%s without locator error = %v, want 定位守护进程 validation", cmd.Name(), err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("%s validation error must keep stdout empty, got %q", cmd.Name(), out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,10 @@ func TestDaemonStatusJSON(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
// --json is a published supervisor API: health fields remain at top level.
|
||||
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"data"`) {
|
||||
t.Fatalf("status compatibility JSON was enveloped: %s", buf.String())
|
||||
}
|
||||
if report.State != healthHealthy {
|
||||
t.Errorf("state = %q, want %q", report.State, healthHealthy)
|
||||
}
|
||||
@@ -281,7 +285,7 @@ func TestDaemonStopNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "ghost"); err != nil {
|
||||
if _, err := daemonStopResult(&buf, "ghost"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
@@ -295,7 +299,7 @@ func TestDaemonStopStaleCleansPidFile(t *testing.T) {
|
||||
dir, _ := connectDaemonDir("stalestop")
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "stalestop"); err != nil {
|
||||
if _, err := daemonStopResult(&buf, "stalestop"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
|
||||
|
||||
@@ -84,7 +84,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand())
|
||||
doc.AddCommand(newDevDocSearchCommand(runner))
|
||||
|
||||
root.AddCommand(
|
||||
newDevAppCommand(runner),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type countingDevUnifiedRunner struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingDevUnifiedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls++
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"content": map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"id": "dev-1", "hasMore": false},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newDevUnifiedRoot(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(cmd)
|
||||
return err
|
||||
}
|
||||
root.AddCommand(devHandler{}.Command(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
func TestDevAppUnifiedActiveExecutesOnceAndReturnsFrameworkResult(t *testing.T) {
|
||||
runner := &countingDevUnifiedRunner{}
|
||||
root := newDevUnifiedRoot(runner)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs([]string{"dev", "app", "list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls=%d, want exactly 1", runner.calls)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || !bytes.Contains(stdout.Bytes(), []byte(`"id": "dev-1"`)) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedDevAppDefaultsToUnifiedFramework(t *testing.T) {
|
||||
runner := &countingDevUnifiedRunner{}
|
||||
root := newDevUnifiedRoot(runner)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"dev", "app", "list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls=%d, want 1", runner.calls)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || bytes.Contains(stdout.Bytes(), []byte(`"contract_version"`)) {
|
||||
t.Fatalf("migrated dev command did not use unified output by default: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevDocSearchStaysLegacyUntilPagePaginationContractExists(t *testing.T) {
|
||||
cmd := newDevDocSearchCommand(&countingDevUnifiedRunner{})
|
||||
if got := output.CommandRollout(cmd); got != output.RolloutLegacyOnly {
|
||||
t.Fatalf("dev doc search rollout=%s, want legacy_only until page pagination is modeled", got)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatal("dev doc search is missing ContractFinal")
|
||||
}
|
||||
if final.Result != nil || final.Pagination != nil {
|
||||
t.Fatalf("legacy dev doc search must not publish unified result/pagination schema: %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevTerminalRolloutKeepsPublishedConnectStatusLegacy(t *testing.T) {
|
||||
root := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
dev, _, err := root.Find([]string{"dev"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var walk func(*cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
children := cmd.Commands()
|
||||
if cmd.Runnable() && len(children) == 0 {
|
||||
want := output.RolloutUnifiedActive
|
||||
switch cmd.CommandPath() {
|
||||
case "dws dev connect status", "dws dev connect list", "dws dev doc search":
|
||||
want = output.RolloutLegacyOnly
|
||||
}
|
||||
if got := output.CommandRollout(cmd); got != want {
|
||||
t.Errorf("%s rollout=%s, want %s", cmd.CommandPath(), got, want)
|
||||
}
|
||||
}
|
||||
if cmd.CommandPath() == "dws dev connect" && output.CommandRollout(cmd) != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s must remain legacy until a streaming contract exists", cmd.CommandPath())
|
||||
}
|
||||
for _, child := range children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(dev)
|
||||
}
|
||||
|
||||
func TestDevConnectStatusPreservesPublishedHumanAndJSONShapes(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
humanRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
var humanOut bytes.Buffer
|
||||
humanRoot.SetOut(&humanOut)
|
||||
humanRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing"})
|
||||
if err := humanRoot.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(humanOut.Bytes(), []byte("not_running")) || bytes.Contains(humanOut.Bytes(), []byte(`"outcome"`)) {
|
||||
t.Fatalf("default status output no longer uses the legacy human view: %s", humanOut.String())
|
||||
}
|
||||
|
||||
jsonRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
var jsonOut bytes.Buffer
|
||||
jsonRoot.SetOut(&jsonOut)
|
||||
jsonRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing", "--json"})
|
||||
if err := jsonRoot.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("legacy --json output is invalid: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if payload["state"] != "not_running" || payload["supervised"] != false {
|
||||
t.Fatalf("legacy health fields moved or changed: %#v", payload)
|
||||
}
|
||||
for _, forbidden := range []string{"ok", "outcome", "data"} {
|
||||
if _, exists := payload[forbidden]; exists {
|
||||
t.Fatalf("legacy --json unexpectedly gained unified key %q: %#v", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+490
-11
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -79,6 +80,36 @@ const (
|
||||
devAppVersionStatusTool = "get_dev_app_version_status"
|
||||
)
|
||||
|
||||
func devAppPaginatedItemsResult(description, itemDescription string) *contract.ResultSpec {
|
||||
schema, _ := json.Marshal(map[string]any{
|
||||
"type": "object",
|
||||
"description": description,
|
||||
"additionalProperties": true,
|
||||
"properties": map[string]any{
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": itemDescription + ";分页控制信息只读取 meta.pagination",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"items"},
|
||||
})
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
func devAppCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
}
|
||||
}
|
||||
|
||||
// newDevAppCommand builds the `app` subtree of `dws dev`. The cobra path is
|
||||
// `dws dev app ...` while the MCP product id stays "devapp" — the id is a
|
||||
// backend contract (SupplementServers/StaticServers injection key and the
|
||||
@@ -289,6 +320,8 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询应用已订阅的事件列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页应用订阅事件查询结果", "当前页事件记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出或搜索应用可订阅的事件",
|
||||
@@ -402,7 +435,12 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询开放平台企业内部应用列表",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"当前页应用查询结果","properties":{"items":{"type":"array","description":"当前页应用记录","items":{"type":"object","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"}},"additionalProperties":true}}},"required":["items"],"additionalProperties":true}`),
|
||||
},
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按条件分页查询开放平台应用",
|
||||
UseWhen: []string{"需要按名称、创建人或应用键筛选应用时"},
|
||||
@@ -445,7 +483,11 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询开放平台企业内部应用详情",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台企业内部应用详情","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"},"agentId":{"description":"应用 Agent ID;具体类型由服务端返回"},"status":{"description":"应用当前状态;具体类型由服务端返回"}},"required":["unifiedAppId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定开放平台应用详情",
|
||||
UseWhen: []string{"已知 unifiedAppId 或 appKey 并需要核对应用配置或状态时"},
|
||||
@@ -565,7 +607,12 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "读取开放平台应用凭证",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台应用凭证;敏感字段必须经过统一脱敏策略","properties":{"clientId":{"type":"string","description":"OAuth 客户端 ID"},"clientSecret":{"type":"string","description":"OAuth 客户端密钥"},"appKey":{"type":"string","description":"应用 AppKey"},"appSecret":{"type":"string","description":"应用 AppSecret"}},"additionalProperties":true}`),
|
||||
SensitivePaths: []string{"appSecret", "clientSecret"},
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取指定应用的客户端凭证",
|
||||
UseWhen: []string{"已知 unifiedAppId 且需要 clientId 或 clientSecret 时"},
|
||||
@@ -863,9 +910,12 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
CanonicalPath: "dev.list_dev_app_permissions",
|
||||
CLIPath: "dev app permission list",
|
||||
PrimaryCLIPath: "dev app permission list",
|
||||
Aliases: []string{"dev app permission search"},
|
||||
},
|
||||
Description: "查询开放平台应用权限列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页开放平台应用权限查询结果", "当前页权限记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询应用权限及其授权状态",
|
||||
@@ -1537,6 +1587,8 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "分页查询应用版本列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页开放平台应用版本查询结果", "当前页版本记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页列出应用的历史和待发布版本",
|
||||
@@ -1596,8 +1648,23 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command
|
||||
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
|
||||
},
|
||||
ConstParams: map[string]any{"precheckOnly": true},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMeta(devAppVersionPublishTool),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "version_check_approval", CanonicalPath: "dev.version_check_approval",
|
||||
CLIPath: "dev app version check-approval", PrimaryCLIPath: "dev app version check-approval",
|
||||
},
|
||||
Description: "预检版本发布是否需要审批,不执行发布",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "预检应用版本的审批要求和候选审批人",
|
||||
UseWhen: []string{"发布版本前确认是否需要审批及候选审批人"},
|
||||
AvoidWhen: []string{"实际发布版本时使用 dev app version publish"},
|
||||
Examples: []string{"dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId>"},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMeta(devAppVersionPublishTool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1661,7 +1728,11 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询版本发布/审批状态",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomePending, contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"应用版本发布或审批状态","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"versionId":{"type":"string","description":"应用版本 ID"},"status":{"type":"string","description":"归一化版本状态"},"versionStatus":{"type":"string","description":"服务端版本状态"},"approvalStatus":{"type":"string","description":"版本审批状态"},"nextCommand":{"type":"string","description":"状态未终结时可执行的下一条命令"}},"required":["versionId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定应用版本的发布或审批状态",
|
||||
UseWhen: []string{"需要判断版本是否已发布、审核中或受阻时"},
|
||||
@@ -1703,6 +1774,9 @@ func devAppLeafMeta(cmd *cobra.Command, tool string) {
|
||||
cmd.DisableAutoGenTag = true
|
||||
preferLegacyLeaf(cmd)
|
||||
annotateDevAppTool(cmd, tool)
|
||||
// This leaf has migrated to the unified result framework. Consumers keep using --format;
|
||||
// the active contract is a release property, not an Agent-selected flag.
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
}
|
||||
|
||||
// devAppCall 返回统一派发闭包(替代各命令重复的 Call: runDevAppTool 透传)。
|
||||
@@ -1776,6 +1850,9 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The requested tool is the local contract authority. Do not let an empty or
|
||||
// stale runner echo disable tool-specific fail-closed projection rules.
|
||||
result.Invocation.Tool = tool
|
||||
// Unwrap the ServiceResult envelope and apply per-tool response fixes before
|
||||
// rendering, so agents read the inner payload directly and pretty-annotation
|
||||
// walks the already-normalized content.
|
||||
@@ -1783,7 +1860,409 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
|
||||
if devAppPrettyWanted(cmd) {
|
||||
devAppPrettyAnnotate(tool, result.Response)
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
return writeDevAppEnvelope(cmd, result)
|
||||
}
|
||||
|
||||
func devAppCommandResult(result executor.Result) output.CommandResult {
|
||||
data := devAppEnvelopeData(result)
|
||||
if content, ok := data.(map[string]any); ok {
|
||||
if partial := devAppMultiProfileResult(content); partial != nil {
|
||||
return partial
|
||||
}
|
||||
if failure := devAppFailureResult(content); failure != nil {
|
||||
return failure
|
||||
}
|
||||
// check-approval reports what a later publish would require; it does not
|
||||
// itself accept an asynchronous operation.
|
||||
precheckOnly, _ := result.Invocation.Params["precheckOnly"].(bool)
|
||||
if !precheckOnly {
|
||||
if pending := devAppPendingResult(content); pending != nil {
|
||||
return pending
|
||||
}
|
||||
}
|
||||
}
|
||||
successOptions := make([]output.ResultOption, 0, 2)
|
||||
if result.Invocation.DryRun {
|
||||
successOptions = append(successOptions, output.WithDryRun())
|
||||
}
|
||||
if meta, err := devAppPaginationMeta(data); err != nil {
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(),
|
||||
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
|
||||
})
|
||||
} else if meta != nil {
|
||||
successOptions = append(successOptions, output.WithMeta(meta))
|
||||
return output.Success(devAppDataWithoutPagination(data), successOptions...)
|
||||
} else if devAppToolRequiresPagination(result.Invocation.Tool) && !result.Invocation.DryRun {
|
||||
// A dry-run payload is a completed local invocation preview, not a
|
||||
// server list response. Only real responses must prove pagination.
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Subtype: "pagination_inconsistent",
|
||||
Message: "declared paginated response is missing hasMore and nextCursor",
|
||||
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
|
||||
})
|
||||
}
|
||||
return output.Success(data, successOptions...)
|
||||
}
|
||||
|
||||
func devAppToolRequiresPagination(tool string) bool {
|
||||
switch strings.TrimSpace(tool) {
|
||||
case devAppListTool, devAppPermissionListTool, devAppEventListTool, devAppVersionListTool:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DevAppCommandResultFromPayload is the shared dingtalk-dev outcome mapper for
|
||||
// the native `dev ...` tree and the existing `devapp +...` shortcut tree. Both
|
||||
// entry points must classify the same upstream payload into the same unified
|
||||
// outcome; only command routing and projection differ.
|
||||
func DevAppCommandResultFromPayload(tool string, payload any, dryRun bool) output.CommandResult {
|
||||
response := map[string]any{"content": payload}
|
||||
if object, ok := payload.(map[string]any); ok {
|
||||
if _, wrapped := object["content"]; wrapped {
|
||||
response = object
|
||||
}
|
||||
}
|
||||
result := executor.Result{
|
||||
Invocation: executor.Invocation{
|
||||
Implemented: true,
|
||||
Kind: "helper_invocation",
|
||||
DryRun: dryRun,
|
||||
Tool: tool,
|
||||
},
|
||||
Response: response,
|
||||
}
|
||||
result = normalizeDevAppServiceResult(result)
|
||||
if strings.TrimSpace(tool) != "" {
|
||||
result = normalizeDevAppToolResult(tool, result)
|
||||
}
|
||||
return devAppCommandResult(result)
|
||||
}
|
||||
|
||||
// writeDevRolloutResult is the gradual migration seam shared by dingtalk-dev.
|
||||
// The operation is executed exactly once; only the renderer changes. Legacy
|
||||
// remains active only for commands that have not advanced to unified_active.
|
||||
func writeDevRolloutResult(cmd *cobra.Command, result output.CommandResult, legacy *output.Envelope, fallback output.Format) error {
|
||||
if output.UsesUnifiedResult(cmd) {
|
||||
return output.StoreResult(cmd.Context(), result)
|
||||
}
|
||||
if output.CommandRollout(cmd) == output.RolloutDualValidate {
|
||||
// Shadow-build/validate the unified result without a second business invocation and
|
||||
// without changing stdout. Metrics can be added around this seam later.
|
||||
if err := output.ValidateResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return output.WriteEnvelope(cmd, legacy, fallback)
|
||||
}
|
||||
|
||||
// writeDevAppEnvelope 是 dev app 全树的统一信封出口(统一输出 dev 域试点,
|
||||
// 队列 Phase F)。成功 → ok:true/outcome:success + data;--dry-run →
|
||||
// ok:true/outcome:success + dry_run:true:dry-run 是已完成的预演,不是异步未终态。
|
||||
// exit 0,参数非法仍走 validation 报错——错误路径继续由 apperrors 通道承载)。
|
||||
// json(默认)输出完整信封(唯一 JSON 契约);其余 format 渲染业务数据。
|
||||
// 复用 internal/output 的权威 Envelope 类型与 WriteEnvelope 出口函数。
|
||||
func writeDevAppEnvelope(cmd *cobra.Command, result executor.Result) error {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Outcome: output.OutcomeSuccess,
|
||||
Data: devAppEnvelopeData(result),
|
||||
}
|
||||
if result.Invocation.DryRun {
|
||||
env.DryRun = true
|
||||
} else {
|
||||
env.Meta, _ = devAppPaginationMeta(env.Data)
|
||||
}
|
||||
return writeDevRolloutResult(cmd, devAppCommandResult(result), env, output.FormatJSON)
|
||||
}
|
||||
|
||||
// devAppEnvelopeData 从工具调用结果中提取业务载荷(L2):已实现的
|
||||
// helper/compat 调用把载荷放在 response.content 下;其余形态(如 dry-run
|
||||
// 预演的 Result 整体)原样透传,与 output.unwrapCompatRuntimePayload 的
|
||||
// 解包规则保持一致,保证 data 即既有消费方看到的载荷。
|
||||
func devAppEnvelopeData(result executor.Result) any {
|
||||
if result.Invocation.Implemented {
|
||||
switch result.Invocation.Kind {
|
||||
case "compat_invocation", "helper_invocation":
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// devAppPaginationMeta 把列表载荷里的 cursor 分页字段投影到 meta.pagination
|
||||
// (契约规范 §3:分页元数据挂 meta 层)。CLI 只观察服务端
|
||||
// 返回的 hasMore/nextCursor,不做合成。hasMore=true 且带 nextCursor →
|
||||
// endpoint_exhausted:false + next_token(可续跑);hasMore=false →
|
||||
// endpoint_exhausted:true。hasMore=true 却无 cursor 时不产出分页元数据,
|
||||
// 避免违反「endpoint_exhausted:false 必须携带 next_token」。统一结果通过
|
||||
// devAppDataWithoutPagination 从 data 剥离源控制字段;legacy renderer 不变。
|
||||
func devAppPaginationMeta(payload any) (*output.Meta, error) {
|
||||
m, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rawHasMore, hasFlag := m["hasMore"]
|
||||
hasMore, hasMoreBool := rawHasMore.(bool)
|
||||
if hasFlag && !hasMoreBool {
|
||||
return nil, fmt.Errorf("pagination hasMore must be a JSON boolean")
|
||||
}
|
||||
cursor := ""
|
||||
rawCursor, hasCursor := m["nextCursor"]
|
||||
if hasCursor {
|
||||
value, stringOK := rawCursor.(string)
|
||||
if !stringOK {
|
||||
return nil, fmt.Errorf("pagination nextCursor must be a JSON string")
|
||||
}
|
||||
cursor = strings.TrimSpace(value)
|
||||
}
|
||||
if !hasFlag && !hasCursor {
|
||||
return nil, nil
|
||||
}
|
||||
pg := &output.Pagination{}
|
||||
switch {
|
||||
case hasMore && cursor != "":
|
||||
pg.EndpointExhausted = false
|
||||
pg.NextToken = cursor
|
||||
case hasFlag && hasMore:
|
||||
return nil, fmt.Errorf("pagination hasMore=true is missing nextCursor")
|
||||
case hasFlag && !hasMore:
|
||||
// DingTalk may echo a terminal cursor even when hasMore=false. The
|
||||
// boolean is the authoritative exhaustion signal; never expose that
|
||||
// non-resumable cursor as meta.pagination.next_token.
|
||||
pg.EndpointExhausted = true
|
||||
case !hasFlag && hasCursor && cursor != "":
|
||||
pg.EndpointExhausted = false
|
||||
pg.NextToken = cursor
|
||||
default:
|
||||
return nil, fmt.Errorf("pagination nextCursor is empty without an exhaustion signal")
|
||||
}
|
||||
return &output.Meta{Pagination: pg}, nil
|
||||
}
|
||||
|
||||
func devAppDataWithoutPagination(payload any) any {
|
||||
object, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return payload
|
||||
}
|
||||
data := make(map[string]any, len(object))
|
||||
for key, value := range object {
|
||||
switch key {
|
||||
case "hasMore", "nextCursor":
|
||||
continue
|
||||
default:
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func devAppMultiProfileResult(content map[string]any) output.CommandResult {
|
||||
if !devAppContentBool(content, "multiProfile") {
|
||||
return nil
|
||||
}
|
||||
profiles, ok := content["profiles"].([]any)
|
||||
if !ok || len(profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
succeeded := make([]any, 0, len(profiles))
|
||||
failed := make([]output.PartialFailedEntry, 0)
|
||||
unknown := make([]output.PartialUnknownEntry, 0)
|
||||
for i, raw := range profiles {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
unknown = append(unknown, output.PartialUnknownEntry{
|
||||
ID: fmt.Sprintf("profile-%d", i+1),
|
||||
Reason: "profile result is malformed; terminal state cannot be confirmed",
|
||||
})
|
||||
continue
|
||||
}
|
||||
id := devAppFirstContentString(entry, "selector", "profile")
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("profile-%d", i+1)
|
||||
}
|
||||
if devAppContentBool(entry, "ok") {
|
||||
preserved := make(map[string]any, len(entry)+1)
|
||||
for key, value := range entry {
|
||||
preserved[key] = value
|
||||
}
|
||||
if _, exists := preserved["id"]; !exists {
|
||||
preserved["id"] = id
|
||||
}
|
||||
succeeded = append(succeeded, preserved)
|
||||
continue
|
||||
}
|
||||
errorInfo := &output.ErrorInfo{Type: "api", Message: "profile execution failed"}
|
||||
if errorMap, ok := entry["error"].(map[string]any); ok {
|
||||
errorInfo = devAppErrorInfo(errorMap, "profile execution failed")
|
||||
if category := devAppFirstContentString(errorMap, "type", "category"); category != "" {
|
||||
errorInfo.Type = devAppWireErrorType(category)
|
||||
}
|
||||
errorInfo.Subtype = devAppFirstContentString(errorMap, "subtype", "reason")
|
||||
errorInfo.Stage = devAppFirstContentString(errorMap, "stage")
|
||||
errorInfo.Origin = devAppFirstContentString(errorMap, "origin")
|
||||
errorInfo.Operation = devAppFirstContentString(errorMap, "operation")
|
||||
errorInfo.RequestID = devAppFirstContentString(errorMap, "request_id", "requestId")
|
||||
errorInfo.TraceID = devAppFirstContentString(errorMap, "trace_id", "traceId")
|
||||
errorInfo.Hint = devAppFirstContentString(errorMap, "hint")
|
||||
if retryable, present := errorMap["retryable"].(bool); present {
|
||||
errorInfo.Retryable = retryable
|
||||
}
|
||||
if executionStarted, present := errorMap["execution_started"].(bool); present {
|
||||
errorInfo.ExecutionStarted = &executionStarted
|
||||
}
|
||||
if actions, present := errorMap["actions"].([]string); present {
|
||||
errorInfo.Actions = append([]string(nil), actions...)
|
||||
} else if rawActions, present := errorMap["actions"].([]any); present {
|
||||
for _, rawAction := range rawActions {
|
||||
if action, ok := rawAction.(string); ok {
|
||||
errorInfo.Actions = append(errorInfo.Actions, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if details, present := errorMap["details"].(map[string]any); present {
|
||||
errorInfo.Details = details
|
||||
}
|
||||
}
|
||||
failed = append(failed, output.PartialFailedEntry{ID: id, Error: errorInfo})
|
||||
}
|
||||
if len(failed) == 0 && len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(succeeded) == 0 {
|
||||
details := make([]any, 0, len(failed)+len(unknown))
|
||||
for _, entry := range failed {
|
||||
details = append(details, map[string]any{"id": entry.ID, "error": entry.Error})
|
||||
}
|
||||
for _, entry := range unknown {
|
||||
details = append(details, map[string]any{"id": entry.ID, "unknown_reason": entry.Reason})
|
||||
}
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Message: "no selected profile has a confirmed success",
|
||||
Details: map[string]any{"profiles": details},
|
||||
})
|
||||
}
|
||||
partial, err := output.NewPartialData(len(profiles), succeeded, failed, unknown)
|
||||
if err != nil {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: err.Error()})
|
||||
}
|
||||
return output.Partial(partial)
|
||||
}
|
||||
|
||||
func devAppFailureResult(content map[string]any) output.CommandResult {
|
||||
status := strings.ToUpper(devAppFirstContentString(content, "status", "taskStatus", "versionStatus", "processStatus"))
|
||||
if rawSuccess, present := content["success"]; present {
|
||||
success, isBool := rawSuccess.(bool)
|
||||
if !isBool {
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Subtype: "invalid_success_type",
|
||||
Message: "dev response success field must be a JSON boolean",
|
||||
Hint: "写操作先核查目标状态;读取操作保留脱敏响应证据后排查上游。",
|
||||
Operation: "devapp.response_projection",
|
||||
})
|
||||
}
|
||||
if !success {
|
||||
return output.Failure(devAppErrorInfo(content, "dev operation failed"))
|
||||
}
|
||||
}
|
||||
if status == "FAIL" || status == "FAILED" || status == "EXPIRED" {
|
||||
return output.Failure(devAppErrorInfo(content, "dev operation "+strings.ToLower(status)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func devAppErrorInfo(content map[string]any, fallback string) *output.ErrorInfo {
|
||||
message := devAppFirstContentString(content, "errorMsg", "errorMessage", "message")
|
||||
if message == "" {
|
||||
message = fallback
|
||||
}
|
||||
info := &output.ErrorInfo{Type: "api", Message: message}
|
||||
if code, ok := content["errorCode"]; ok {
|
||||
info.UpstreamCode = code
|
||||
} else if code, ok := content["code"]; ok {
|
||||
info.UpstreamCode = code
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func devAppWireErrorType(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "api", "auth", "validation", "permission", "discovery", "internal":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
case "authorization", "forbidden":
|
||||
return "permission"
|
||||
default:
|
||||
// Upstream transport labels such as network/timeout are subtypes of an
|
||||
// API operation failure, not new top-level Agent branch keys.
|
||||
return "api"
|
||||
}
|
||||
}
|
||||
|
||||
func devAppPendingResult(content map[string]any) output.CommandResult {
|
||||
state := strings.ToUpper(devAppFirstContentString(content,
|
||||
"completionState", "status", "taskStatus", "versionStatus", "processStatus", "approvalStatus"))
|
||||
nonTerminal := !devAppContentBool(content, "terminal") && (devAppContentBool(content, "mustContinue") || devAppContentBool(content, "mustAskUser"))
|
||||
approvalPending := devAppContentBool(content, "approvalSubmitted") && !devAppContentBool(content, "published")
|
||||
isPendingState := state == "WAITING" || state == "PENDING" || state == "PROCESSING" || state == "AUDIT" ||
|
||||
state == "UNDER_REVIEW" || strings.HasPrefix(state, "WAITING_") || strings.HasPrefix(state, "BLOCKED_BY_")
|
||||
if !isPendingState && !nonTerminal && !approvalPending {
|
||||
return nil
|
||||
}
|
||||
if state == "" {
|
||||
state = "WAITING_FOR_ACTION"
|
||||
}
|
||||
id := devAppFirstContentString(content, "taskId", "versionId", "unifiedAppId", "requestId")
|
||||
if id == "" {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing an operation identifier"})
|
||||
}
|
||||
next := devAppFirstNextCommand(content)
|
||||
if next == "" {
|
||||
next = devAppRecoveryCommand(content)
|
||||
}
|
||||
if next == "" {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing a recovery command"})
|
||||
}
|
||||
return output.Pending(content, &output.OperationInfo{ID: id, State: strings.ToLower(state), NextCommand: next})
|
||||
}
|
||||
|
||||
func devAppRecoveryCommand(content map[string]any) string {
|
||||
if taskID := devAppFirstContentString(content, "taskId"); taskID != "" {
|
||||
return fmt.Sprintf("dws dev app robot result --task-id %s --format json", taskID)
|
||||
}
|
||||
appID := devAppFirstContentString(content, "unifiedAppId")
|
||||
versionID := devAppFirstContentString(content, "versionId")
|
||||
if appID != "" && versionID != "" {
|
||||
return fmt.Sprintf("dws dev app version status --unified-app-id %s --version-id %s --format json", appID, versionID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func devAppFirstNextCommand(content map[string]any) string {
|
||||
steps, ok := content["nextSteps"].([]map[string]any)
|
||||
if ok {
|
||||
for _, step := range steps {
|
||||
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
|
||||
return command
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSteps, ok := content["nextSteps"].([]any); ok {
|
||||
for _, raw := range rawSteps {
|
||||
if step, ok := raw.(map[string]any); ok {
|
||||
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
|
||||
return command
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeDevAppServiceResult unwraps the op-app ServiceResult envelope
|
||||
@@ -1909,7 +2388,7 @@ func normalizeDevAppVersionApproval(content map[string]any) {
|
||||
},
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "publish_version",
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --yes --format json", unifiedAppID, versionID),
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --format json", unifiedAppID, versionID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --dry-run --format json", unifiedAppID, versionID),
|
||||
DoneWhen: "approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;published=true 表示已发布",
|
||||
Blocking: true,
|
||||
@@ -2031,7 +2510,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
|
||||
steps := []map[string]any{
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "create_version",
|
||||
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --yes --format json", appID),
|
||||
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --format json", appID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --dry-run --format json", appID),
|
||||
DoneWhen: "返回 versionId",
|
||||
Blocking: true,
|
||||
@@ -2044,7 +2523,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
|
||||
}),
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "publish_version",
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --yes --format json", appID),
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --format json", appID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --dry-run --format json", appID),
|
||||
DoneWhen: "published=true 表示已发布;approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;SELECT_APPROVER 时必须先让用户从 approvalCandidates 选择审批人后追加 --approver-user-id",
|
||||
RequiresUserInput: true,
|
||||
@@ -2095,7 +2574,7 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any {
|
||||
}
|
||||
return devAppNextStep(devAppStep{
|
||||
ID: "retry_robot_submit",
|
||||
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --yes --format json", taskIDFlag),
|
||||
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --format json", taskIDFlag),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --dry-run --format json", taskIDFlag),
|
||||
DoneWhen: "返回新的 WAITING taskId;FAIL 场景优先复用原 taskId,EXPIRED 场景重新提交",
|
||||
Blocking: true,
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -417,11 +419,9 @@ func connectLocalDebugNotice() string {
|
||||
return "[connect] 提示:本地调试,不代表线上发布完成;dev connect 只建立本地 Stream,不会提交版本发布。若机器人来自 APPROVAL_REQUIRED,仍需继续执行 version create → check-approval → publish → status。\n"
|
||||
}
|
||||
|
||||
// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that
|
||||
// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top
|
||||
// level), so an agent can parse "is this a dry-run preview" the same way across
|
||||
// all dev commands. The connect-specific fields (channel/cli/connect/...) sit
|
||||
// inside, since connect is a linking pre-check, not an MCP tool call.
|
||||
// connectPreviewEnvelope preserves the established streaming-command preview
|
||||
// shape. `dev connect` remains legacy until a dedicated streaming contract is
|
||||
// available; terminal child commands migrate independently.
|
||||
func connectPreviewEnvelope(fields map[string]any) map[string]any {
|
||||
fields["kind"] = "connect_preview"
|
||||
fields["dry_run"] = true
|
||||
@@ -489,6 +489,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
daemonMode, _ := cmd.Flags().GetBool(daemonFlag)
|
||||
|
||||
// Credential resolution: explicit pair wins; otherwise reuse dev app's
|
||||
// credentials get against --unified-app-id.
|
||||
@@ -558,7 +559,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
// --daemon: detach into a background supervisor that keeps the
|
||||
// connector alive 7x24. We resolve credentials/channel first (above) so
|
||||
// the parent fails fast on bad input before forking, then re-exec.
|
||||
if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode {
|
||||
if daemonMode {
|
||||
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
|
||||
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
|
||||
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
|
||||
@@ -614,6 +615,29 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
|
||||
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
|
||||
cmd.Flags().String("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
// Foreground connect is a long-lived stream and remains legacy until a
|
||||
// dedicated streaming contract exists. Terminal child commands migrate
|
||||
// independently.
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "connect", CanonicalPath: "dev.connect",
|
||||
CLIPath: "dev connect", PrimaryCLIPath: "dev connect",
|
||||
},
|
||||
Description: "把现有机器人连接到本地 agent;该命令同时承载前台流与 --daemon,整体暂留 legacy,终态子命令独立迁移",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合远端凭证获取、Stream 建连与本地守护进程管理,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把现有机器人连接到本地 agent 进行调试",
|
||||
UseWhen: []string{"需要启动本地机器人 Stream 调试连接"},
|
||||
AvoidWhen: []string{"创建或发布应用版本时使用 dev app"},
|
||||
Examples: []string{"dws dev connect --daemon --unified-app-id <unifiedAppId>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDevConnectListPreservesPublishedTableAndJSONArray(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
healthyDir, err := connectDaemonDir(daemonDirKey("dingAAA", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir(healthy): %v", err)
|
||||
}
|
||||
writeJSON(t, connectHeartbeatPath(healthyDir), connectHeartbeat{
|
||||
Pid: os.Getpid(), Channel: "codex", ClientID: "dingAAA",
|
||||
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
|
||||
})
|
||||
downDir, err := connectDaemonDir(daemonDirKey("dingBBB", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir(down): %v", err)
|
||||
}
|
||||
writeJSON(t, connectHeartbeatPath(downDir), connectHeartbeat{
|
||||
Pid: deadPid(t), Channel: "opencode", ClientID: "dingBBB",
|
||||
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
|
||||
})
|
||||
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
tableOut, tableErr, err := runRootBuffered(t, root, "dev", "connect", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list error = %v\nstderr:\n%s", err, tableErr.String())
|
||||
}
|
||||
table := tableOut.String()
|
||||
for _, value := range []string{"STATE", "CLIENT", "PID", "CHANNEL", "dingAAA", "dingBBB", "codex", "opencode"} {
|
||||
if !strings.Contains(table, value) {
|
||||
t.Fatalf("legacy table missing %q:\n%s", value, table)
|
||||
}
|
||||
}
|
||||
if strings.Contains(table, `"outcome"`) || strings.Contains(table, `"ok"`) {
|
||||
t.Fatalf("legacy table was enveloped:\n%s", table)
|
||||
}
|
||||
|
||||
jsonRoot := newDevAppTestRoot(&captureRunner{})
|
||||
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list --json error = %v\nstderr:\n%s", err, jsonErr.String())
|
||||
}
|
||||
var reports []connectHealthReport
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
|
||||
t.Fatalf("legacy --json is not a top-level array: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if len(reports) != 2 || strings.Contains(jsonOut.String(), `"outcome"`) || strings.Contains(jsonOut.String(), `"data"`) {
|
||||
t.Fatalf("legacy --json changed shape: %s", jsonOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevConnectListPreservesPublishedEmptyState(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
humanOut, humanErr, err := runRootBuffered(t, root, "dev", "connect", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list empty error = %v\nstderr:\n%s", err, humanErr.String())
|
||||
}
|
||||
if strings.TrimSpace(humanOut.String()) != "no connectors found" {
|
||||
t.Fatalf("legacy empty output=%q", humanOut.String())
|
||||
}
|
||||
|
||||
jsonRoot := newDevAppTestRoot(&captureRunner{})
|
||||
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list empty --json error = %v\nstderr:\n%s", err, jsonErr.String())
|
||||
}
|
||||
var reports []connectHealthReport
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
|
||||
t.Fatalf("legacy empty --json is not an array: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if reports == nil || len(reports) != 0 {
|
||||
t.Fatalf("legacy empty --json=%s, want []", jsonOut.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// phaseFConnectRoot 构造 stdout/stderr 分流的可观测命令树根,供 connect 族
|
||||
// 流纪律断言使用(统一输出 dev 域试点,队列 B108/B109/B116)。
|
||||
func phaseFConnectRoot(t *testing.T, runner *captureRunner) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(runner)
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
return root, &stdout, &stderr
|
||||
}
|
||||
|
||||
type phaseFEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func decodePhaseFEnvelope(t *testing.T, raw []byte) *phaseFEnvelope {
|
||||
t.Helper()
|
||||
var env phaseFEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("stdout is not a single valid JSON envelope: %v\n%s", err, raw)
|
||||
}
|
||||
if (env.Outcome == "success" || env.Outcome == "pending") && !env.OK {
|
||||
t.Fatalf("envelope invariant I1 violated: ok=false with outcome=%q\n%s", env.Outcome, raw)
|
||||
}
|
||||
return &env
|
||||
}
|
||||
|
||||
// decodePhaseFConnectPreview parses the established legacy preview. The
|
||||
// streaming root remains legacy while terminal children migrate independently.
|
||||
func decodePhaseFConnectPreview(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(raw, &document); err != nil {
|
||||
t.Fatalf("stdout is not a single valid JSON document: %v\n%s", err, raw)
|
||||
}
|
||||
preview, ok := document["invocation"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("legacy connect preview missing invocation: %s", raw)
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
// TestDevConnectCustomChannelPreviewEnvelope 是队列 B108:--agent-cmd 自定义
|
||||
// 渠道(custom)的 dry-run 预览信封断言。--agent-cmd 是 custom 渠道的语法糖:
|
||||
// 未显式 --channel 时强制 custom,预览保持原有 invocation JSON shape。
|
||||
func TestDevConnectCustomChannelPreviewEnvelope(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
// RunE 内部用 os.Setenv 写 DWS_AGENT_CMD(不随 t.Setenv 恢复),显式清理防串测。
|
||||
t.Cleanup(func() { os.Unsetenv("DWS_AGENT_CMD") })
|
||||
|
||||
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--agent-cmd", "lobster -p",
|
||||
"--robot-client-id", "cid-1",
|
||||
"--robot-client-secret", "sec-1",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
data := decodePhaseFConnectPreview(t, stdout.Bytes())
|
||||
if data == nil {
|
||||
t.Fatalf("preview envelope data is nil: %s", stdout.String())
|
||||
}
|
||||
if data["channel"] != "custom" {
|
||||
t.Fatalf("channel = %#v, want custom (--agent-cmd 糖强制 custom): %s", data["channel"], stdout.String())
|
||||
}
|
||||
if data["kind"] != "connect_preview" || data["scope"] != "local_debug_only" {
|
||||
t.Fatalf("preview markers kind/scope = %#v/%#v: %s", data["kind"], data["scope"], stdout.String())
|
||||
}
|
||||
if data["clientId"] != "cid-1" {
|
||||
t.Fatalf("clientId = %#v, want cid-1: %s", data["clientId"], stdout.String())
|
||||
}
|
||||
if cred, _ := data["credentialSource"].(string); !strings.HasPrefix(cred, "flag:") {
|
||||
t.Fatalf("credentialSource = %#v, want flag:* 前缀: %s", data["credentialSource"], stdout.String())
|
||||
}
|
||||
if data["terminal"] != false || data["doesNotPublish"] != true {
|
||||
t.Fatalf("terminal/doesNotPublish = %#v/%#v, want false/true: %s",
|
||||
data["terminal"], data["doesNotPublish"], stdout.String())
|
||||
}
|
||||
if _, ok := data["connect"].(map[string]any); !ok {
|
||||
t.Fatalf("connect plan missing from preview data: %s", stdout.String())
|
||||
}
|
||||
|
||||
// secret 落 argv 的人读警告只走 stderr;stdout 不得混入人读文案。
|
||||
if !strings.Contains(stderr.String(), "--robot-client-secret 出现在命令行") {
|
||||
t.Fatalf("argv secret warning missing from stderr: %q", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "[connect]") {
|
||||
t.Fatalf("stdout must carry only the envelope, human-readable text leaked: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd 是队列 B116 的 dry-run
|
||||
// 端到端分支:--unified-app-id 路径的 dry-run 必须跳过 credentials get
|
||||
// (runner 零调用),预览信封如实标注凭证来源为「unified-app-id(skipped)」。
|
||||
func TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
runner := &captureRunner{}
|
||||
root, stdout, stderr := phaseFConnectRoot(t, runner)
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--channel", "hermes",
|
||||
"--unified-app-id", "u-9",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
data := decodePhaseFConnectPreview(t, stdout.Bytes())
|
||||
if data == nil {
|
||||
t.Fatalf("preview envelope data is nil: %s", stdout.String())
|
||||
}
|
||||
if data["channel"] != "hermes" {
|
||||
t.Fatalf("channel = %#v, want hermes: %s", data["channel"], stdout.String())
|
||||
}
|
||||
if data["unifiedAppId"] != "u-9" {
|
||||
t.Fatalf("unifiedAppId = %#v, want u-9: %s", data["unifiedAppId"], stdout.String())
|
||||
}
|
||||
if data["credentialSource"] != "unified-app-id (credentials get, skipped in dry-run)" {
|
||||
t.Fatalf("credentialSource = %#v, want dry-run skipped marker: %s", data["credentialSource"], stdout.String())
|
||||
}
|
||||
// dry-run 禁止真实调用 credentials get。
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("dry-run must not invoke any tool, got %q", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectForegroundStdoutDiscipline 是队列 B109:前台启动路径的
|
||||
// channel/凭证来源提示行与本地调试声明保持 stderr,stdout 严格零字节
|
||||
// (契约规范 §5.1:stdout 只承载数据;前台 connector 的后续输出由被 stub 的
|
||||
// stream 层承载,本测试验证 dws 自身不向 stdout 写任何人读文案)。
|
||||
func TestDevConnectForegroundStdoutDiscipline(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_CMD", "")
|
||||
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origStream := devAppRunStreamConnector
|
||||
t.Cleanup(func() { devAppRunStreamConnector = origStream })
|
||||
devAppRunStreamConnector = func(context.Context, string, string, string, forwarder, *aiCardClient, *connectExtras) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--channel", "custom",
|
||||
"--robot-client-id", "cid-1",
|
||||
"--robot-client-secret", "sec-1",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("foreground connect must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
errText := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[connect] channel=custom",
|
||||
"凭证来源=flag:--robot-client-id/--robot-client-secret",
|
||||
"不代表线上发布完成",
|
||||
} {
|
||||
if !strings.Contains(errText, want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", want, errText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
// registerDevAppCursorFlags adds the two cursor flags every list/search command
|
||||
// exposes. pageSize defaults to 20.
|
||||
func registerDevAppCursorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次出参的 nextCursor")
|
||||
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次 meta.pagination.next_token")
|
||||
cmd.Flags().Int("page-size", 20, "单页条数,默认 20")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// collectDevLeafCommands 递归收集命令树中所有叶子命令(无子命令者)。
|
||||
func collectDevLeafCommands(cmd *cobra.Command, out *[]*cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if len(cmd.Commands()) == 0 {
|
||||
*out = append(*out, cmd)
|
||||
return
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
collectDevLeafCommands(sub, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevRepresentativeResultContractsReachContractFinal(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
finals := make(map[string]contract.ContractFinalPayload)
|
||||
for _, leaf := range leaves {
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if ok && final.Identity != nil {
|
||||
finals[final.Identity.CanonicalPath] = final
|
||||
}
|
||||
}
|
||||
|
||||
paginationCheck := func(t *testing.T, final contract.ContractFinalPayload) {
|
||||
t.Helper()
|
||||
if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor ||
|
||||
final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
||||
t.Fatalf("paginated list contract = %#v", final)
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
canonical string
|
||||
outcomes []contract.ResultOutcome
|
||||
check func(*testing.T, contract.ContractFinalPayload)
|
||||
}{
|
||||
{
|
||||
canonical: "dev.list_dev_app",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
check: paginationCheck,
|
||||
},
|
||||
{canonical: "dev.list_dev_app_permissions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.list_dev_app_events", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.list_dev_app_versions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.get_dev_app", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
{
|
||||
canonical: "dev.get_dev_app_credentials",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
check: func(t *testing.T, final contract.ContractFinalPayload) {
|
||||
result := final.Result
|
||||
if want := []string{"appSecret", "clientSecret"}; !reflect.DeepEqual(result.SensitivePaths, want) {
|
||||
t.Fatalf("sensitive paths = %#v, want %#v", result.SensitivePaths, want)
|
||||
}
|
||||
},
|
||||
},
|
||||
{canonical: "dev.get_dev_app_version_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure}},
|
||||
{canonical: "dev.connect_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
{canonical: "dev.connect_list", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.canonical, func(t *testing.T) {
|
||||
final, ok := finals[test.canonical]
|
||||
if !ok || final.Result == nil {
|
||||
t.Fatalf("representative leaf %s has no final Result", test.canonical)
|
||||
}
|
||||
if !reflect.DeepEqual(final.Result.Outcomes, test.outcomes) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, test.outcomes)
|
||||
}
|
||||
if len(final.Result.DataSchema) == 0 {
|
||||
t.Fatal("data_schema is empty")
|
||||
}
|
||||
if test.check != nil {
|
||||
test.check(t, final)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafDeclarationCompleteness 是队列 B190 的 dev 叶子声明完整性自查:
|
||||
// 每一个**注册了 ContractFinal** 的 dev 叶子都必须满足 Identity+Safety 最小集
|
||||
// (无半壳:有注册但字段缺失)。对未注册 ContractFinal 的叶子(裸 Cobra 迁移态
|
||||
// 或 NewLeafCommand 漏声明)不硬失败,而是汇总为 findings 清单打印,供人工判断
|
||||
// 是否属「空壳入册」。
|
||||
func TestDevLeafDeclarationCompleteness(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
|
||||
if len(leaves) == 0 {
|
||||
t.Fatalf("no dev leaves collected under test root")
|
||||
}
|
||||
t.Logf("dev domain leaves under test: %d", len(leaves))
|
||||
|
||||
var noFinal []string
|
||||
for _, leaf := range leaves {
|
||||
path := leaf.CommandPath()
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok {
|
||||
noFinal = append(noFinal, path)
|
||||
continue
|
||||
}
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if final.Identity == nil {
|
||||
t.Fatalf("leaf %q ContractFinal.Identity is nil", path)
|
||||
}
|
||||
if final.Identity.ProductID == "" {
|
||||
t.Fatalf("leaf %q Identity.ProductID empty", path)
|
||||
}
|
||||
if final.Identity.Name == "" {
|
||||
t.Fatalf("leaf %q Identity.Name empty", path)
|
||||
}
|
||||
if final.Identity.CanonicalPath == "" {
|
||||
t.Fatalf("leaf %q Identity.CanonicalPath empty", path)
|
||||
}
|
||||
if final.Safety == nil {
|
||||
t.Fatalf("leaf %q ContractFinal.Safety is nil (Safety minimal set missing)", path)
|
||||
}
|
||||
if final.Description == "" {
|
||||
t.Fatalf("leaf %q ContractFinal.Description empty (no description declared)", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(noFinal) > 0 {
|
||||
sort.Strings(noFinal)
|
||||
t.Logf("B190 findings: %d dev leaves without ContractFinal (may be intentional bare-Cobra migration or empty-shell NewLeafCommand):", len(noFinal))
|
||||
for _, p := range noFinal {
|
||||
t.Logf(" - %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafDeclarationNoEmptyShellNewLeaf 是队列 B190 的核心断言:NewLeafCommand
|
||||
// 完全托管的叶子必须注册 ContractFinal——声明了 LeafSpec 却无 ContractFinal 就是
|
||||
// 「空壳入册」。本测试通过 Verify 一个已知的既存漏洞(version check-approval 用
|
||||
// NewLeafCommand 但 LeafSpec 未声明 Contract)来固化当前现状,并断言这类「声明了
|
||||
// Shell 却无最终契约」的问题在受控已知集中,而非蔓延到其它托管叶子。
|
||||
func TestDevLeafDeclarationNoEmptyShellNewLeaf(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
|
||||
knownEmptyShell := map[string]bool{}
|
||||
|
||||
var unexpected []string
|
||||
for _, leaf := range leaves {
|
||||
path := leaf.CommandPath()
|
||||
if _, ok := contractfinal.RuntimeContractFinal(leaf); ok {
|
||||
continue // 正常托管叶子已有 ContractFinal。
|
||||
}
|
||||
// 无 ContractFinal:裸 Cobra 迁移态(connect 系列)或已知空壳。
|
||||
if knownEmptyShell[path] {
|
||||
continue
|
||||
}
|
||||
// 裸 Cobra 命令(不经 NewLeafCommand)不要求 ContractFinal,排除。
|
||||
if isBareDevCobraLeaf(leaf) {
|
||||
continue
|
||||
}
|
||||
unexpected = append(unexpected, path)
|
||||
}
|
||||
if len(unexpected) > 0 {
|
||||
t.Fatalf("unexpected empty-shell NewLeafCommand leaves without ContractFinal: %v", unexpected)
|
||||
}
|
||||
}
|
||||
|
||||
// isBareDevCobraLeaf 判断某 dev 叶子是否为裸 Cobra 命令(不经 NewLeafCommand,
|
||||
// 不注册 ContractFinal)。通过多次构造命令并检查其是否有本地 flags 来间接识别
|
||||
// 不可靠,改用命令名白名单:connect 本地运维子命令为已知裸 Cobra 迁移态。
|
||||
func isBareDevCobraLeaf(cmd *cobra.Command) bool {
|
||||
switch cmd.Name() {
|
||||
case "list", "status", "restart", "stop":
|
||||
// 仅当命令位于 dev connect 子树下才算裸 Cobra(避免误伤 dev app 的
|
||||
// version list 等托管叶子)。
|
||||
if strings.Contains(cmd.CommandPath(), "dev connect") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
_ = contractfinal.HasRuntimeContractFinal
|
||||
_ = sort.Strings
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// devAppRegressionLeafPaths 遍历 dev app 命令树,收集全部叶子路径(无子命令
|
||||
// 即叶子)。核销清单的「清单 ⇆ 命令树」双向绑定依赖它:新增叶子若不在清单
|
||||
// 里,清单测试失败,强制信封覆盖随叶子同批落盘(AC-28/M1.8)。
|
||||
func devAppRegressionLeafPaths(t *testing.T) []string {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{}))
|
||||
appCmd, _, err := root.Find([]string{"dev", "app"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(dev app) error = %v", err)
|
||||
}
|
||||
var leaves []string
|
||||
var traverse func(c *cobra.Command, prefix []string)
|
||||
traverse = func(c *cobra.Command, prefix []string) {
|
||||
if !c.HasSubCommands() {
|
||||
leaves = append(leaves, strings.Join(prefix, " "))
|
||||
return
|
||||
}
|
||||
for _, sub := range c.Commands() {
|
||||
traverse(sub, append(append([]string{}, prefix...), sub.Name()))
|
||||
}
|
||||
}
|
||||
traverse(appCmd, nil)
|
||||
sort.Strings(leaves)
|
||||
return leaves
|
||||
}
|
||||
|
||||
// devAppEnvelopeRegressionChecklist 是 dev app 树 32 叶子的信封核销清单
|
||||
// (队列 B138,AC-28/M1.8):每条给出可执行 argv。读叶子走成功路径
|
||||
// (outcome=success),写叶子带 --dry-run 走预览路径(outcome=success +
|
||||
// dry_run=true)。argv 与轮 11-B0 族验证测试同源(各叶子 flag 已逐一核对)。
|
||||
func devAppEnvelopeRegressionChecklist() []struct {
|
||||
path string
|
||||
args []string
|
||||
write bool
|
||||
} {
|
||||
return []struct {
|
||||
path string
|
||||
args []string
|
||||
write bool
|
||||
}{
|
||||
{"list", []string{"dev", "app", "list", "--name", "DemoApp"}, false},
|
||||
{"get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"create", []string{"dev", "app", "create", "--name", "DemoApp", "--dry-run"}, true},
|
||||
{"update", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}, true},
|
||||
{"delete", []string{"dev", "app", "delete", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"disable", []string{"dev", "app", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"enable", []string{"dev", "app", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"credentials get", []string{"dev", "app", "credentials", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"webapp get", []string{"dev", "app", "webapp", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"webapp config", []string{"dev", "app", "webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com", "--dry-run"}, true},
|
||||
{"permission list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"permission add", []string{"dev", "app", "permission", "add", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
|
||||
{"permission remove", []string{"dev", "app", "permission", "remove", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
|
||||
{"member list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"member add", []string{"dev", "app", "member", "add", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
|
||||
{"member remove", []string{"dev", "app", "member", "remove", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
|
||||
{"security config", []string{"dev", "app", "security", "config", "--unified-app-id", "u-1", "--redirect-urls", "https://cb.example.invalid/cb", "--dry-run"}, true},
|
||||
{"robot submit", []string{"dev", "app", "robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--dry-run"}, true},
|
||||
{"robot result", []string{"dev", "app", "robot", "result", "--task-id", "t-1"}, false},
|
||||
{"robot get", []string{"dev", "app", "robot", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"robot config", []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--dry-run"}, true},
|
||||
{"robot enable", []string{"dev", "app", "robot", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"robot disable", []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"version create", []string{"dev", "app", "version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人", "--dry-run"}, true},
|
||||
{"version list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"version get", []string{"dev", "app", "version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"version check-approval", []string{"dev", "app", "version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"version publish", []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--dry-run"}, true},
|
||||
{"version status", []string{"dev", "app", "version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"event list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"event subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
|
||||
{"event unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppEnvelopeRegressionLeafInventory 是核销清单的清单 ⇆ 命令树双向
|
||||
// 绑定:dev app 树叶子集合必须与清单完全一致。新增叶子未补信封断言、或
|
||||
// 清单残留已删除叶子,都会在此失败。
|
||||
func TestDevAppEnvelopeRegressionLeafInventory(t *testing.T) {
|
||||
tree := devAppRegressionLeafPaths(t)
|
||||
|
||||
want := make([]string, 0)
|
||||
for _, entry := range devAppEnvelopeRegressionChecklist() {
|
||||
want = append(want, entry.path)
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
if len(tree) != len(want) {
|
||||
t.Fatalf("leaf count = %d, checklist = %d\ntree: %v\nchecklist: %v",
|
||||
len(tree), len(want), tree, want)
|
||||
}
|
||||
for i := range tree {
|
||||
if tree[i] != want[i] {
|
||||
t.Fatalf("leaf[%d] = %q, checklist = %q\ntree: %v\nchecklist: %v",
|
||||
i, tree[i], want[i], tree, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// regressionEnvelope 是核销断言的信封解码形状:ok 用 *bool——缺席(未走
|
||||
// 统一出口)为 nil,`"ok":"true"` 字符串形态(违反 AC-02)直接解码失败。
|
||||
type regressionEnvelope struct {
|
||||
OK *bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// TestDevAppEnvelopeRegressionUnifiedExit 逐一执行 32 叶子并断言 stdout 恰为
|
||||
// 一个统一信封(走 writeDevAppEnvelope 唯一出口):ok 为 JSON 布尔 true、
|
||||
// outcome 与读/写形态一致、data 非空。任何绕过信封直写 stdout 的叶子
|
||||
// (legacy 裸 JSON/人读文案)都会因缺 ok 键或 JSON 解析失败而暴露。
|
||||
func TestDevAppEnvelopeRegressionUnifiedExit(t *testing.T) {
|
||||
for _, entry := range devAppEnvelopeRegressionChecklist() {
|
||||
t.Run(entry.path, func(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}),
|
||||
entry.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
|
||||
var env regressionEnvelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout is not a single JSON envelope (unified exit bypass?): %v\n%s", err, out.String())
|
||||
}
|
||||
if env.OK == nil {
|
||||
t.Fatalf("envelope missing ok key (unified exit bypass?): %s", out.String())
|
||||
}
|
||||
if !*env.OK {
|
||||
t.Fatalf("ok = false on success/pending path (I1 violated): %s", out.String())
|
||||
}
|
||||
if entry.write {
|
||||
if env.Outcome != "success" || !env.DryRun {
|
||||
t.Fatalf("write leaf dry-run outcome/dry_run = %q/%v, want success/true: %s",
|
||||
env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
} else {
|
||||
if env.Outcome != "success" || env.DryRun {
|
||||
t.Fatalf("read leaf outcome/dry_run = %q/%v, want success/false: %s",
|
||||
env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("envelope data is nil: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// runDevDomainErrorCase 在生产口径的静默根下执行错误路径用例(队列
|
||||
// B128~B137)。生产根命令设 SilenceUsage/SilenceErrors=true
|
||||
// (internal/app/root.go),错误时 Cobra 不向 stdout 打 usage/error;测试根
|
||||
// 对齐同一口径,使「错误路径 stdout 零字节」断言与真实调用一致。
|
||||
func runDevDomainErrorCase(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(runner)
|
||||
root.SilenceUsage = true
|
||||
root.SilenceErrors = true
|
||||
var out, errBuf bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errBuf)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return &out, &errBuf, err
|
||||
}
|
||||
|
||||
// requireDevValidationError 断言错误是结构化 apperrors validation(AC-03):
|
||||
// Category=validation + rc=3 + 消息可定位。
|
||||
func requireDevValidationError(t *testing.T, err error, wantMsg string) *apperrors.Error {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want structured validation error containing %q", wantMsg)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) {
|
||||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", err.Error(), wantMsg)
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
// TestDevAppGetMissingLocatorStructuredValidation 是队列 B128:dev app get
|
||||
// 无参数二选一报结构化 validation(rc=3),stdout 零字节。
|
||||
func TestDevAppGetMissingLocatorStructuredValidation(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, "dev", "app", "get")
|
||||
requireDevValidationError(t, err, "请传入 --unified-app-id 或 --app-key")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectUnknownChannelStructuredValidation 是队列 B129:dev connect
|
||||
// 未知渠道报结构化 validation(NewValidation,rc=3),stdout 零字节。
|
||||
func TestDevConnectUnknownChannelStructuredValidation(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "--channel", "bogus")
|
||||
requireDevValidationError(t, err, `未知渠道 "bogus"`)
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectMissingCredentialsNonInteractiveStructuredValidation 是队列
|
||||
// B130:非交互环境缺凭证不进引导流程,直接报结构化 validation(rc=3)。
|
||||
// stdin 交互判定经 seam 显式置 false,保证脚本/CI 环境断言确定性。
|
||||
func TestDevConnectMissingCredentialsNonInteractiveStructuredValidation(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
origInteractive := devAppConnectStdinInteractive
|
||||
t.Cleanup(func() { devAppConnectStdinInteractive = origInteractive })
|
||||
devAppConnectStdinInteractive = func() bool { return false }
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "--channel", "hermes")
|
||||
requireDevValidationError(t, err, "需要 --robot-client-id/--robot-client-secret")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectRestartMissingRecordStructuredValidation 是队列 B131 的结构化
|
||||
// 增量:restart 无连接器记录报结构化 validation(rc=3)。错误消息与 stdout 零
|
||||
// 字节断言已由轮 11-B0 B116(TestConnectDaemonFamilyMissingDaemonErrorPaths)
|
||||
// 覆盖,本测试补 AC-03 的 Category/rc 映射面。
|
||||
func TestDevConnectRestartMissingRecordStructuredValidation(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "restart", "--robot-client-id", "ghost")
|
||||
requireDevValidationError(t, err, "未找到连接器记录")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectStatusDaemonDirResolutionFailure 是队列 B133:status 的
|
||||
// daemon 目录解析失败(override 指向普通文件使 MkdirAll 失败)报结构化
|
||||
// internal 错误(rc=5),stdout 零字节。
|
||||
func TestDevConnectStatusDaemonDirResolutionFailure(t *testing.T) {
|
||||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("setup blocked file: %v", err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "status", "--robot-client-id", "ghost", "--json")
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want daemon dir resolution failure")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) {
|
||||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryInternal {
|
||||
t.Fatalf("category = %q, want internal", appErr.Category)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 5 {
|
||||
t.Fatalf("exit code = %d, want 5 (internal)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolve daemon dir") {
|
||||
t.Fatalf("message = %q, want contains resolve daemon dir", err.Error())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppCreateMissingNameErrorPath 是队列 B134:create 缺 --name 报错、
|
||||
// stdout 零字节。LeafSpec RequiredHint 由 corecmd 统一转成 typed validation,
|
||||
// 与手写参数校验共享 rc=3。
|
||||
func TestDevAppCreateMissingNameErrorPath(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "create", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--name 为必填") {
|
||||
t.Fatalf("error = %v, want --name 为必填", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppVersionPublishMissingParamsErrorPath 是队列 B135:version publish
|
||||
// 参数缺失报错、stdout 零字节。ValidateRequired 按声明顺序报第一个缺失的
|
||||
// 必填参数(先 unified-app-id 后 version-id);两者均为 typed validation rc=3。
|
||||
func TestDevAppVersionPublishMissingParamsErrorPath(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "version", "publish", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
|
||||
out2, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "version", "publish", "--yes", "--unified-app-id", "u-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--version-id 为必填") {
|
||||
t.Fatalf("error = %v, want --version-id 为必填", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out2.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppDeleteDisableEnableMissingUnifiedAppID 是队列 B136:
|
||||
// delete/disable/enable 缺统一应用标识报错、stdout 零字节,均由统一 required
|
||||
// preflight 返回 typed validation rc=3。
|
||||
func TestDevAppDeleteDisableEnableMissingUnifiedAppID(t *testing.T) {
|
||||
for _, leaf := range []string{"delete", "disable", "enable"} {
|
||||
t.Run(leaf, func(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", leaf, "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevDomainErrorPathsKeepStdoutEmpty 是队列 B137 的总断言(AC-11):
|
||||
// dev 域全部错误路径 stdout 严格零字节。dev 域失败走 apperrors 通道(生产
|
||||
// root 错误处理器写 stderr),Phase F 不引入 failure 信封(轮 8 裁决⑪),
|
||||
// 故 AC-11 在测试层的投影即「stdout 零字节」。各用例的结构化/消息断言见
|
||||
// 上述分测试,本表只锁流纪律。
|
||||
func TestDevDomainErrorPathsKeepStdoutEmpty(t *testing.T) {
|
||||
daemonDirSetup := func(t *testing.T) {
|
||||
t.Helper()
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setup func(t *testing.T)
|
||||
}{
|
||||
{"app get missing locator", []string{"dev", "app", "get"}, nil},
|
||||
{"app update no field", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--dry-run"}, nil},
|
||||
{"app create missing name", []string{"dev", "app", "create", "--yes"}, nil},
|
||||
{"app delete missing id", []string{"dev", "app", "delete", "--yes"}, nil},
|
||||
{"app disable missing id", []string{"dev", "app", "disable", "--yes"}, nil},
|
||||
{"app enable missing id", []string{"dev", "app", "enable", "--yes"}, nil},
|
||||
{"version publish missing params", []string{"dev", "app", "version", "publish", "--yes"}, nil},
|
||||
{"connect unknown channel", []string{"dev", "connect", "--channel", "bogus"}, nil},
|
||||
{"connect status missing locator", []string{"dev", "connect", "status"}, nil},
|
||||
{"connect restart missing record", []string{"dev", "connect", "restart", "--robot-client-id", "ghost"}, daemonDirSetup},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.setup != nil {
|
||||
tc.setup(t)
|
||||
}
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, tc.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil\nstdout:\n%s", out.String())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty (AC-11), got %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
func TestDevFormerExclusionsHaveFinalContractAndUnifiedRollout(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
for _, tc := range []struct {
|
||||
path []string
|
||||
rollout output.RolloutState
|
||||
}{
|
||||
{[]string{"dev", "connect", "list"}, output.RolloutLegacyOnly},
|
||||
{[]string{"dev", "connect", "restart"}, output.RolloutUnifiedActive},
|
||||
{[]string{"dev", "app", "version", "check-approval"}, output.RolloutUnifiedActive},
|
||||
} {
|
||||
cmd, _, err := root.Find(tc.path)
|
||||
if err != nil || cmd == nil || !cmd.Runnable() {
|
||||
t.Fatalf("%v is not a runnable command: cmd=%v err=%v", tc.path, cmd, err)
|
||||
}
|
||||
if _, ok := contractfinal.RuntimeContractFinal(cmd); !ok {
|
||||
t.Fatalf("%s has no ContractFinal", cmd.CommandPath())
|
||||
}
|
||||
if got := output.CommandRollout(cmd); got != tc.rollout {
|
||||
t.Fatalf("%s rollout=%s, want %s", cmd.CommandPath(), got, tc.rollout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestDevLeafFormatFlagVisibility 是队列 B189 的 dev 域叶子 --format flag 可见性
|
||||
// 断言。--format 是生产根命令的持久 flag(internal/app/flags.go bindPersistentFlags),
|
||||
// 叶子自身不声明 local --format,而是经 cmd.InheritedFlags() 继承可见。本测试:
|
||||
//
|
||||
// 1. 在完整命令树下执行 dev app/dev connect 叶子,--format 必须被 resolveCommandFormat
|
||||
// 识别(table 生效),证明其可见;
|
||||
//
|
||||
// 2. 叶子 --help 必须展示 --format 于 Global Flags 段(继承可见性的人读证据)。
|
||||
func TestDevLeafFormatFlagVisibility(t *testing.T) {
|
||||
leaves := []struct {
|
||||
name string
|
||||
args []string
|
||||
helpPath []string
|
||||
}{
|
||||
{"dev-app-get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, []string{"dev", "app", "get"}},
|
||||
{"dev-app-list", []string{"dev", "app", "list", "--name", "DemoApp"}, []string{"dev", "app", "list"}},
|
||||
{"dev-version-list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "version", "list"}},
|
||||
{"dev-event-list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "event", "list"}},
|
||||
{"dev-permission-list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "permission", "list"}},
|
||||
{"dev-member-list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "member", "list"}},
|
||||
}
|
||||
|
||||
for _, lc := range leaves {
|
||||
t.Run(lc.name, func(t *testing.T) {
|
||||
// --format table 必须生效:输出为表(含列头/业务值),且不泄漏信封外壳键。
|
||||
args := append(append([]string{}, lc.args...), "--format", "table")
|
||||
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{"items": []any{map[string]any{"id": "one"}}}))
|
||||
out, errBuf, err := runRootBuffered(t, root, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok":`) {
|
||||
t.Fatalf("--format table not effective (envelope shell leaked):\n%s", s)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
t.Fatalf("--format table produced empty stdout (format not visible?):\n%s", s)
|
||||
}
|
||||
|
||||
// --help 必须展示 --format 于 Global Flags 段。
|
||||
helpArgs := append(append([]string{}, lc.helpPath...), "--help")
|
||||
helpRoot := newDevAppTestRoot(&captureRunner{})
|
||||
helpOut, _, herr := runRootBuffered(t, helpRoot, helpArgs...)
|
||||
if herr != nil {
|
||||
t.Fatalf("--help error = %v", herr)
|
||||
}
|
||||
if !strings.Contains(helpOut.String(), "--format") {
|
||||
t.Fatalf("leaf --help must expose --format (global inherited flag):\n%s", helpOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafFormatFlagHelpShowsGlobalSection 是队列 B189 的补充观察断言:dev 叶子
|
||||
// 的 --help 中 --format 出现在 "Global Flags" 段而非本地 Flags 段(flag 可见性来源
|
||||
// 为根命令持久继承,而非叶子自声明)。这是 B189 结论的形式化——若某叶子把
|
||||
// --format 放回本地段,本测试会因 --format 出现在非 Global 段而暴露。
|
||||
func TestDevLeafFormatFlagHelpShowsGlobalSection(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
out, _, err := runRootBuffered(t, root, "dev", "app", "get", "--help")
|
||||
if err != nil {
|
||||
t.Fatalf("--help error = %v", err)
|
||||
}
|
||||
help := out.String()
|
||||
globalIdx := strings.Index(help, "Global Flags")
|
||||
if globalIdx < 0 {
|
||||
t.Fatalf("leaf help missing 'Global Flags' section:\n%s", help)
|
||||
}
|
||||
formatIdx := strings.LastIndex(help, "--format")
|
||||
if formatIdx < 0 {
|
||||
t.Fatalf("leaf help missing --format in Global Flags:\n%s", help)
|
||||
}
|
||||
if formatIdx < globalIdx {
|
||||
t.Fatalf("--format found before 'Global Flags' section (should be inherited global):\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = cobra.Command{}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// devAppFormatMatrixCases 是 B117 分发表驱动用例集:读叶子(成功路径)与
|
||||
// 写叶子(--dry-run pending 路径)各取一个代表,覆盖 json/table/pretty/
|
||||
// csv/ndjson/raw 六种 format。契约规范 §5.2:json = 完整信封(唯一 JSON
|
||||
// 契约);table/pretty/csv/ndjson/raw = 仅 data 通道(不输出信封外壳)。
|
||||
func TestDevAppFormatMatrixDispatching(t *testing.T) {
|
||||
readArgs := []string{"dev", "app", "get", "--unified-app-id", "u-1"}
|
||||
writeArgs := []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}
|
||||
|
||||
readContent := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
writeContent := map[string]any{"unifiedAppId": "u-1", "name": "NewName"}
|
||||
|
||||
// 每个 format 的断言:'envelope' 表示 stdout 必须是完整信封(默认 json、
|
||||
// 未知降级);'data' 表示必须只渲染 data(无信封外壳键)。
|
||||
cases := []struct {
|
||||
name string
|
||||
format string
|
||||
wantKind string // "envelope" | "data"
|
||||
}{
|
||||
{"json", "json", "envelope"},
|
||||
{"table", "table", "data"},
|
||||
{"pretty", "pretty", "data"},
|
||||
{"csv", "csv", "data"},
|
||||
{"ndjson", "ndjson", "data"},
|
||||
{"raw", "raw", "data"},
|
||||
}
|
||||
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
args []string
|
||||
content map[string]any
|
||||
wantMarker string
|
||||
}{
|
||||
{"read", readArgs, readContent, "DemoApp"},
|
||||
{"write-dryrun", writeArgs, writeContent, "NewName"},
|
||||
} {
|
||||
for _, tc := range cases {
|
||||
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, mode.args...)
|
||||
args = append(args, "--format", tc.format)
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(mode.content), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if !strings.Contains(s, mode.wantMarker) {
|
||||
t.Fatalf("stdout missing business value %q:\n%s", mode.wantMarker, s)
|
||||
}
|
||||
switch tc.wantKind {
|
||||
case "envelope":
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("-f %s stdout is not a JSON envelope: %v\n%s", tc.format, err, s)
|
||||
}
|
||||
if env["ok"] != true {
|
||||
t.Fatalf("-f %s envelope ok = %#v, want true", tc.format, env["ok"])
|
||||
}
|
||||
case "data":
|
||||
// 非 json 只渲染 data:不得泄漏信封外壳键。
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`, `"dry_run"`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("-f %s leaked envelope wrapper key %s:\n%s", tc.format, banned, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 成功/dry-run 数据路径 stderr 应无噪声(除 dry-run 外的
|
||||
// 无 format warning 场景)。
|
||||
if strings.Contains(errBuf.String(), "[WARN]") {
|
||||
t.Fatalf("-f %s unexpected warning on stderr: %q", tc.format, errBuf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFormatMatrixUnknownDegradesToJSON 是 B117 的未知值降级断言:
|
||||
// 未知 --format 走 AC-09 降级为完整 JSON 信封 + stderr 单行 warning,数据不丢。
|
||||
// 与轮12 全链路降级回归(TestUnknownFormatFullChainDegradesOverDevAppCommand)
|
||||
// 同口径,此处作为分发表驱动矩阵的一行固化。
|
||||
func TestDevAppFormatMatrixUnknownDegradesToJSON(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "bogus")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
var env regressionEnvelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unknown format must degrade to JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if env.OK == nil || !*env.OK || env.Outcome != "success" {
|
||||
t.Fatalf("degraded envelope malformed: %s", out.String())
|
||||
}
|
||||
if env.Data["name"] != "DemoApp" {
|
||||
t.Fatalf("business payload lost in degradation: %s", out.String())
|
||||
}
|
||||
warning := errBuf.String()
|
||||
if !strings.Contains(warning, "[WARN]") || !strings.Contains(warning, "bogus") {
|
||||
t.Fatalf("stderr warning missing: %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFormatMatrixRawPassThrough 是 B117 raw 语义断言:raw 直接透传
|
||||
// data 载荷(不输出信封外壳)。dev app 成功载荷为 map,raw 即其紧凑 JSON。
|
||||
func TestDevAppFormatMatrixRawPassThrough(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "raw")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if !strings.Contains(s, "DemoApp") {
|
||||
t.Fatalf("raw output missing business value:\n%s", s)
|
||||
}
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("raw leaked envelope wrapper key %s:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
_ = errBuf
|
||||
}
|
||||
|
||||
var _ output.Format = output.FormatJSON
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// TestDevAppEnvelopeJSONMatchesJSONUtilEscaping 是队列 B121 的信封 JSON 与
|
||||
// jsonutil 转义一致性断言:信封 JSON 经 jsonutil.MarshalIndent(HTML 转义
|
||||
// 关闭)序列化,& < > 与 URL 查询分隔符原样保留,不出现 \u003c/\u0026 等
|
||||
// HTML 转义序列。业务载荷含 URL 时不应被转义破坏。
|
||||
func TestDevAppEnvelopeJSONMatchesJSONUtilEscaping(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"homepageUrl": "https://example.com/redirect?dest=a&from=b&raw=<payload>",
|
||||
"name": "Demo<App>&Co",
|
||||
}
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
|
||||
// ① 载荷 URL/特殊字符原样保留(HTML 转义关闭)。
|
||||
for _, want := range []string{"https://example.com/redirect?dest=a&from=b&raw=<payload>", "Demo<App>&Co"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("envelope JSON must preserve raw %q (HTML escaping disabled):\n%s", want, s)
|
||||
}
|
||||
}
|
||||
// ② 不得出现 HTML 转义序列。
|
||||
for _, banned := range []string{"\\u003c", "\\u003e", "\\u0026", "\\u003d", "\\u002f"} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("envelope JSON must not HTML-escape to %q:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 与 jsonutil.MarshalIndent 对同一数据载荷的序列化逐字一致(转义规则同源)。
|
||||
env := map[string]any{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": content,
|
||||
}
|
||||
wantJSON, err := jsonutil.MarshalIndent(env, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent: %v", err)
|
||||
}
|
||||
// 信封 Data 是 content 的强类型 map,序列化键序稳定:比对 data 片段即可。
|
||||
var gotData struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &gotData); err != nil {
|
||||
t.Fatalf("stdout not a JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
wantDataStruct := struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}{Data: content}
|
||||
wantDataJSON, err := jsonutil.MarshalIndent(wantDataStruct, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent(data): %v", err)
|
||||
}
|
||||
gotDataJSON, err := jsonutil.MarshalIndent(gotData, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent(got): %v", err)
|
||||
}
|
||||
if string(gotDataJSON) != string(wantDataJSON) {
|
||||
t.Fatalf("envelope data serialization diverges from jsonutil:\nGOT:\n%s\nWANT:\n%s",
|
||||
gotDataJSON, wantDataJSON)
|
||||
}
|
||||
_ = wantJSON
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,12 @@ type fakeDevAppRunner struct {
|
||||
got executor.Invocation
|
||||
}
|
||||
|
||||
func prepareDevAppLeafTestCommand(cmd *cobra.Command) *cobra.Command {
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f *fakeDevAppRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
f.got = inv
|
||||
// 空 Response:normalizeDevAppServiceResult 无 "content" 时早返回,安全。
|
||||
@@ -39,7 +46,7 @@ func (f *fakeDevAppRunner) Run(_ context.Context, inv executor.Invocation) (exec
|
||||
// toolArgs 键/值/trim 与手写版等价,且 PostMount 设上了 schema 注解与 NoArgs。
|
||||
func TestDevAppCredentialsGetLeafDispatchesTrimmedArgs(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppCredentialsGetCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppCredentialsGetCommand(r))
|
||||
// 含首尾空白:验证 LeafFlag.Trim 等价手写 devAppStringFlag 的 TrimSpace。
|
||||
if err := cmd.Flags().Set("unified-app-id", " APP-123 "); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -64,7 +71,7 @@ func TestDevAppCredentialsGetLeafDispatchesTrimmedArgs(t *testing.T) {
|
||||
// TestDevAppLifecycleLeafWriteGuardAndArgs 验证框架确认门拦截/放行 + toolArgs 装配。
|
||||
func TestDevAppLifecycleLeafWriteGuardAndArgs(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppEnableCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppEnableCommand(r))
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,7 +124,7 @@ func TestDevAppVersionLeafs(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := c.build(r)
|
||||
cmd := prepareDevAppLeafTestCommand(c.build(r))
|
||||
if err := cmd.Flags().Set("unified-app-id", " APP-1 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -150,7 +157,7 @@ func TestDevAppVersionLeafs(t *testing.T) {
|
||||
// TestDevAppMemberListLeaf 验证 member list 的 unified-id 装配。
|
||||
func TestDevAppMemberListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppMemberListCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppMemberListCommand(r))
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -168,7 +175,7 @@ func TestDevAppMemberListLeaf(t *testing.T) {
|
||||
// TestDevAppPermissionListLeaf 验证 ToUpper transform + auth-status 默认 + 命令别名。
|
||||
func TestDevAppPermissionListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppPermissionListCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppPermissionListCommand(r))
|
||||
// 命令别名 "search" 经 PostMount 设回。
|
||||
if !containsString(cmd.Aliases, "search") {
|
||||
t.Fatalf("cmd.Aliases = %v, want contain search", cmd.Aliases)
|
||||
@@ -194,7 +201,7 @@ func TestDevAppPermissionListLeaf(t *testing.T) {
|
||||
// TestDevAppVersionPublishLeaf 验证 precheckOnly=false 常量 + confirmed-sensitive Changed() 语义。
|
||||
func TestDevAppVersionPublishLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppVersionPublishCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppVersionPublishCommand(r))
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-V")
|
||||
@@ -211,7 +218,7 @@ func TestDevAppVersionPublishLeaf(t *testing.T) {
|
||||
}
|
||||
// 显式设 confirmed-sensitive=true:入参。
|
||||
r2 := &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppVersionPublishCommand(r2)
|
||||
cmd2 := prepareDevAppLeafTestCommand(newDevAppVersionPublishCommand(r2))
|
||||
cmd2.Flags().Bool("yes", false, "")
|
||||
_ = cmd2.Flags().Set("yes", "true")
|
||||
_ = cmd2.Flags().Set("unified-app-id", "APP-V")
|
||||
@@ -227,7 +234,7 @@ func TestDevAppVersionPublishLeaf(t *testing.T) {
|
||||
func TestDevAppGetLeafDualKey(t *testing.T) {
|
||||
// 都不传:报错(框架统一约束措辞)。
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppGetCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppGetCommand(r))
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "请传入 --unified-app-id 或 --app-key") {
|
||||
t.Fatalf("err = %v, want 二选一报错", err)
|
||||
@@ -250,7 +257,7 @@ func TestDevAppGetLeafDualKey(t *testing.T) {
|
||||
func TestDevAppListLeaf(t *testing.T) {
|
||||
// keyword 别名回退:只设 keyword,name 应取 keyword 值。
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppListCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppListCommand(r))
|
||||
_ = cmd.Flags().Set("keyword", "DemoApp")
|
||||
_ = cmd.Flags().Set("app-group-id", "5")
|
||||
_ = cmd.Flags().Set("develop-type", "0") // 0:LeafInt 不入参
|
||||
@@ -274,7 +281,7 @@ func TestDevAppListLeaf(t *testing.T) {
|
||||
}
|
||||
// name 优先于 keyword。
|
||||
r2 := &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppListCommand(r2)
|
||||
cmd2 := prepareDevAppLeafTestCommand(newDevAppListCommand(r2))
|
||||
_ = cmd2.Flags().Set("name", "Primary")
|
||||
_ = cmd2.Flags().Set("keyword", "Fallback")
|
||||
_ = cmd2.RunE(cmd2, nil)
|
||||
@@ -286,7 +293,7 @@ func TestDevAppListLeaf(t *testing.T) {
|
||||
// TestDevAppEventListLeaf 验证 cursor/pageSize 经 devAppCallCursor 工具注入。
|
||||
func TestDevAppEventListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppEventListCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppEventListCommand(r))
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-E")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
@@ -311,7 +318,7 @@ func TestDevAppEventListLeaf(t *testing.T) {
|
||||
// TestDevAppUpdateLeaf 验证「至少一项」校验 + OmitEmpty 装配。
|
||||
func TestDevAppUpdateLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppUpdateCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppUpdateCommand(r))
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-U")
|
||||
@@ -335,7 +342,7 @@ func TestDevAppUpdateLeaf(t *testing.T) {
|
||||
// TestDevAppMemberAddLeaf 验证 userIds []string 注入 + memberType 装配 + 写守卫/必填顺序。
|
||||
func TestDevAppMemberAddLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppMemberAddCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppMemberAddCommand(r))
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-M")
|
||||
// 无 --yes:写守卫拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
@@ -370,7 +377,7 @@ func TestDevAppMemberAddLeaf(t *testing.T) {
|
||||
// TestDevAppSecurityConfigLeaf 验证 3 个列表 flag 的 Call 注入 + 「至少一项」校验。
|
||||
func TestDevAppSecurityConfigLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppSecurityConfigCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppSecurityConfigCommand(r))
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-S")
|
||||
@@ -396,7 +403,7 @@ func TestDevAppSecurityConfigLeaf(t *testing.T) {
|
||||
// TestDevAppPermissionAddLeaf 验证 scope-values 经 Call 注入为 []string + 写守卫/必填。
|
||||
func TestDevAppPermissionAddLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppPermissionAddCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppPermissionAddCommand(r))
|
||||
// scope-values 在 PostMount 注册。
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -429,7 +436,7 @@ func TestDevAppPermissionAddLeaf(t *testing.T) {
|
||||
// TestDevAppWebappConfigLeaf 验证 OmitEmpty 装配 + 「至少一项」跨 flag 校验。
|
||||
func TestDevAppWebappConfigLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppWebappConfigCommand(r)
|
||||
cmd := prepareDevAppLeafTestCommand(newDevAppWebappConfigCommand(r))
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
|
||||
@@ -447,7 +454,7 @@ func TestDevAppWebappConfigLeaf(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppWebappConfigCommand(r)
|
||||
cmd2 := prepareDevAppLeafTestCommand(newDevAppWebappConfigCommand(r))
|
||||
cmd2.Flags().Bool("yes", false, "")
|
||||
_ = cmd2.Flags().Set("yes", "true")
|
||||
_ = cmd2.Flags().Set("unified-app-id", "APP-1")
|
||||
@@ -493,7 +500,7 @@ func TestDevAppLeafToolArgsTable(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := c.build(r)
|
||||
cmd := prepareDevAppLeafTestCommand(c.build(r))
|
||||
if c.needsYes {
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// runDevAppFamily 在 stdout/stderr 分流下执行 dev app 命令树(统一输出 dev 域
|
||||
// 试点族验证,队列 B64~B106)。返回两路缓冲与执行错误,供信封/流纪律断言。
|
||||
func runDevAppFamily(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(runner)
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
root.SetOut(&outBuf)
|
||||
root.SetErr(&errBuf)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return &outBuf, &errBuf, err
|
||||
}
|
||||
|
||||
// devAppFamilyContentRunner 返回一个把 content 载荷包进 ServiceResult 的 runner,
|
||||
// 模拟 op-app 成功响应(normalizeDevAppServiceResult 会解包到 content)。
|
||||
func devAppFamilyContentRunner(content map[string]any) *devAppResponseRunner {
|
||||
return &devAppResponseRunner{
|
||||
response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": true,
|
||||
"result": content,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func devAppFamilyRawContentRunner(content map[string]any) *devAppResponseRunner {
|
||||
runner := devAppFamilyContentRunner(content)
|
||||
runner.preserveMissingPagination = true
|
||||
return runner
|
||||
}
|
||||
|
||||
// TestDevAppFamilyReadLeavesDualFormat 是队列 B64/B68/B71/B80/B83/B87/B95/B102
|
||||
// 的读叶子双格式验证(AC-28/M1.7):默认 json 出完整信封(ok/outcome/data),
|
||||
// -f table 只渲染 data(不含信封包装键)。业务载荷形状不变。
|
||||
func TestDevAppFamilyReadLeavesDualFormat(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"event list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}},
|
||||
{"app list", []string{"dev", "app", "list", "--name", "DemoApp"}},
|
||||
{"app get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}},
|
||||
{"credentials get", []string{"dev", "app", "credentials", "get", "--unified-app-id", "u-1"}},
|
||||
{"webapp get", []string{"dev", "app", "webapp", "get", "--unified-app-id", "u-1"}},
|
||||
{"permission list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}},
|
||||
{"member list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}},
|
||||
{"robot config get", []string{"dev", "app", "robot", "get", "--unified-app-id", "u-1"}},
|
||||
{"robot result", []string{"dev", "app", "robot", "result", "--task-id", "t-1"}},
|
||||
{"version list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}},
|
||||
{"version get", []string{"dev", "app", "version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}},
|
||||
{"version check-approval", []string{"dev", "app", "version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}},
|
||||
{"version status", []string{"dev", "app", "version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/json", func(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content), tc.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
env := decodePhaseFEnvelope(t, out.Bytes())
|
||||
if !env.OK || env.Outcome != "success" {
|
||||
t.Fatalf("envelope ok/outcome = %v/%q, want true/success: %s", env.OK, env.Outcome, out.String())
|
||||
}
|
||||
if env.DryRun {
|
||||
t.Fatalf("read leaf must not set dry_run: %s", out.String())
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("envelope data is nil: %s", out.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/table", func(t *testing.T) {
|
||||
args := append([]string{}, tc.args...)
|
||||
args = append(args, "--format", "table")
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
// table 只渲染 data:不得出现信封包装键,但要见业务载荷值。
|
||||
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok"`) {
|
||||
t.Fatalf("-f table must render data only, envelope leaked: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "DemoApp") {
|
||||
t.Fatalf("-f table output missing data payload value: %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFamilyWriteLeavesDryRunEnvelope 是队列 B65/B66/B73/B74/B81/B84/B85/
|
||||
// B88/B89/B91/B93/B96/B100/B104 的写叶子 dry-run 预览断言(契约规范 §6 场景6):
|
||||
// --dry-run 出 ok:true/outcome:success + dry_run:true,无需 --yes,参数校验先于确认门。
|
||||
func TestDevAppFamilyWriteLeavesDryRunEnvelope(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"event subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b"}},
|
||||
{"event unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b"}},
|
||||
{"app create", []string{"dev", "app", "create", "--name", "DemoApp"}},
|
||||
{"app update", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName"}},
|
||||
{"app disable", []string{"dev", "app", "disable", "--unified-app-id", "u-1"}},
|
||||
{"app enable", []string{"dev", "app", "enable", "--unified-app-id", "u-1"}},
|
||||
{"webapp config", []string{"dev", "app", "webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com"}},
|
||||
{"permission add", []string{"dev", "app", "permission", "add", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile"}},
|
||||
{"permission remove", []string{"dev", "app", "permission", "remove", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile"}},
|
||||
{"member add", []string{"dev", "app", "member", "add", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER"}},
|
||||
{"member remove", []string{"dev", "app", "member", "remove", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER"}},
|
||||
{"security config", []string{"dev", "app", "security", "config", "--unified-app-id", "u-1", "--redirect-urls", "https://cb.example.invalid/cb"}},
|
||||
{"robot submit", []string{"dev", "app", "robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答"}},
|
||||
{"robot config", []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手"}},
|
||||
{"robot enable", []string{"dev", "app", "robot", "enable", "--unified-app-id", "u-1"}},
|
||||
{"robot disable", []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1"}},
|
||||
{"version create", []string{"dev", "app", "version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人"}},
|
||||
{"version publish", []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, tc.args...)
|
||||
args = append(args, "--dry-run")
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(map[string]any{"ok": true}), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
env := decodePhaseFEnvelope(t, out.Bytes())
|
||||
// dry-run 是已完成的预演,不是尚未终态的异步操作。
|
||||
if !env.OK || env.Outcome != "success" || !env.DryRun {
|
||||
t.Fatalf("dry-run envelope ok/outcome/dry_run = %v/%q/%v, want true/success/true: %s",
|
||||
env.OK, env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
// dry-run 不投影分页 meta。
|
||||
if strings.Contains(out.String(), `"pagination"`) {
|
||||
t.Fatalf("dry-run must not carry pagination meta: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppListPaginationProjectsMeta 是队列 B69(契约规范 §3):列表载荷的
|
||||
// cursor 分页字段投影到 meta.pagination。hasMore=true+nextCursor →
|
||||
// endpoint_exhausted:false + next_token(可续跑);hasMore=false → exhausted:true;
|
||||
// 已声明分页的工具缺少两类标记时 fail-closed,不得伪装为完整成功。
|
||||
func TestDevAppListPaginationProjectsMeta(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
content map[string]any
|
||||
wantNext string
|
||||
wantExh bool
|
||||
wantHasPg bool
|
||||
wantFail bool
|
||||
}{
|
||||
{
|
||||
name: "app list has more with cursor",
|
||||
args: []string{"dev", "app", "list", "--name", "DemoApp"},
|
||||
content: map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "tok-9"},
|
||||
wantNext: "tok-9",
|
||||
wantExh: false,
|
||||
wantHasPg: true,
|
||||
},
|
||||
{
|
||||
name: "app list exhausted",
|
||||
args: []string{"dev", "app", "list", "--name", "DemoApp"},
|
||||
content: map[string]any{"items": []any{}, "hasMore": false},
|
||||
wantExh: true,
|
||||
wantHasPg: true,
|
||||
},
|
||||
{
|
||||
name: "app list no pagination fields",
|
||||
args: []string{"dev", "app", "list", "--name", "DemoApp"},
|
||||
content: map[string]any{"items": []any{}},
|
||||
wantFail: true,
|
||||
},
|
||||
{
|
||||
name: "version list has more with cursor",
|
||||
args: []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"},
|
||||
content: map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "v-tok-3"},
|
||||
wantNext: "v-tok-3",
|
||||
wantExh: false,
|
||||
wantHasPg: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := devAppFamilyContentRunner(tc.content)
|
||||
if tc.wantFail {
|
||||
runner = devAppFamilyRawContentRunner(tc.content)
|
||||
}
|
||||
out, errBuf, err := runDevAppFamily(t, runner, tc.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errBuf.String())
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error *struct {
|
||||
Subtype string `json:"subtype"`
|
||||
} `json:"error"`
|
||||
Meta struct {
|
||||
Pagination *struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if tc.wantFail {
|
||||
if env.OK || env.Outcome != "failure" || env.Error == nil || env.Error.Subtype != "pagination_inconsistent" {
|
||||
t.Fatalf("missing pagination markers must fail closed: %s", out.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tc.wantHasPg {
|
||||
if env.Meta.Pagination != nil {
|
||||
t.Fatalf("pagination must be absent, got %+v: %s", env.Meta.Pagination, out.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if env.Meta.Pagination == nil {
|
||||
t.Fatalf("pagination missing: %s", out.String())
|
||||
}
|
||||
if env.Meta.Pagination.EndpointExhausted != tc.wantExh {
|
||||
t.Fatalf("endpoint_exhausted = %v, want %v: %s",
|
||||
env.Meta.Pagination.EndpointExhausted, tc.wantExh, out.String())
|
||||
}
|
||||
if env.Meta.Pagination.NextToken != tc.wantNext {
|
||||
t.Fatalf("next_token = %q, want %q: %s", env.Meta.Pagination.NextToken, tc.wantNext, out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppListNameFilterPassthrough 是队列 B70:信封迁移不改业务参数——
|
||||
// --name 过滤仍原样透传到工具调用(只观察装配,不改变行为)。
|
||||
func TestDevAppListNameFilterPassthrough(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
out, errBuf, err := runDevAppFamily(t, runner, "dev", "app", "list", "--name", "Waker")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errBuf.String())
|
||||
}
|
||||
if runner.last.Tool != "list_dev_app" {
|
||||
t.Fatalf("Tool = %q, want list_dev_app", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["name"]; got != "Waker" {
|
||||
t.Fatalf("name = %#v, want Waker (信封迁移不得改业务参数)", got)
|
||||
}
|
||||
_ = out
|
||||
}
|
||||
|
||||
// TestDevAppGetLocatorBranches 是队列 B72:--unified-app-id 与 --app-key 两个
|
||||
// 分支各自的信封断言(AC-28)——两者都能出成功信封,业务参数各自透传。
|
||||
func TestDevAppGetLocatorBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantKey string
|
||||
wantVal string
|
||||
}{
|
||||
{"by unified-app-id", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, "unifiedAppId", "u-1"},
|
||||
{"by app-key", []string{"dev", "app", "get", "--app-key", "dingxxx"}, "appKey", "dingxxx"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
out, errBuf, err := runDevAppFamily(t, runner, tc.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errBuf.String())
|
||||
}
|
||||
env := decodePhaseFEnvelope(t, out.Bytes())
|
||||
if !env.OK || env.Outcome != "success" {
|
||||
t.Fatalf("envelope ok/outcome = %v/%q, want true/success: %s", env.OK, env.Outcome, out.String())
|
||||
}
|
||||
if got := runner.last.Params[tc.wantKey]; got != tc.wantVal {
|
||||
t.Fatalf("%s = %#v, want %q", tc.wantKey, got, tc.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppCredentialsSecretFieldsPreserved 是队列 B78:clientSecret 等敏感字段
|
||||
// 经信封迁移后仍原样保留在 data 层(jsonutil 关闭 HTML 转义,无 \u003c 污染)。
|
||||
func TestDevAppCredentialsSecretFieldsPreserved(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"appKey": "dingxxx",
|
||||
"appSecret": "secret-app",
|
||||
"clientSecret": "secret-client<>&",
|
||||
}
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "credentials", "get", "--unified-app-id", "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
for _, want := range []string{"secret-app", "secret-client<>&"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("credentials output missing %q:\n%s", want, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "\\u003c") {
|
||||
t.Fatalf("HTML escaping must be disabled, got \\u003c: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppDeleteDryRunPreviewsWithoutConfirmName 是队列 B75:delete 的
|
||||
// dry-run 无需 --confirm-name 即出 success 预览信封(AC-28/AC-03——真实删除
|
||||
// 才要求二次确认;dry-run 是读应用名的预览路径)。
|
||||
func TestDevAppDeleteDryRunPreviewsWithoutConfirmName(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(map[string]any{"name": "DemoApp"}),
|
||||
"dev", "app", "delete", "--unified-app-id", "u-1", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
env := decodePhaseFEnvelope(t, out.Bytes())
|
||||
if !env.OK || env.Outcome != "success" || !env.DryRun {
|
||||
t.Fatalf("delete dry-run envelope ok/outcome/dry_run = %v/%q/%v, want true/success/true: %s",
|
||||
env.OK, env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
|
||||
// 真实删除(无 --dry-run)缺 --confirm-name 时报 validation 错误(不进信封)。
|
||||
out2, _, err := runDevAppFamily(t, devAppFamilyContentRunner(map[string]any{"name": "DemoApp"}),
|
||||
"dev", "app", "delete", "--unified-app-id", "u-1", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--confirm-name") {
|
||||
t.Fatalf("real delete without confirm-name error = %v, want --confirm-name validation", err)
|
||||
}
|
||||
if strings.Contains(out2.String(), `"outcome": "success"`) {
|
||||
t.Fatalf("delete validation error must not emit success envelope: %s", out2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFamilyValidationErrorsKeepEnvelopeOff 是队列 B67/B79/B82/B86/B90/
|
||||
// B92/B99/B106 的错误路径回归:参数校验失败继续走 apperrors 通道(不进信封),
|
||||
// stdout 不含信封(由 root 错误处理器承接),错误信息可定位。
|
||||
func TestDevAppFamilyValidationErrorsKeepEnvelopeOff(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{"get requires locator", []string{"dev", "app", "get"}, "请传入 --unified-app-id 或 --app-key"},
|
||||
{"event subscribe requires codes", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--dry-run"}, "--event-codes 为必填"},
|
||||
{"member add requires users", []string{"dev", "app", "member", "add", "--unified-app-id", "u-1", "--member-type", "DEVELOPER", "--dry-run"}, "--user-ids 为必填"},
|
||||
{"update requires one field", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--dry-run"}, "至少提供一项待更新字段"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, _, err := runDevAppFamily(t, &captureRunner{}, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
// 校验失败时 stdout 不得出现成功信封。
|
||||
if strings.Contains(out.String(), `"outcome": "success"`) {
|
||||
t.Fatalf("validation error must not emit success envelope: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newPipelineLeafCmd(format string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
cmd.Flags().String("format", format, "")
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
return cmd, &out, &errOut
|
||||
}
|
||||
|
||||
// runDevAppFamilyProdAligned 是 B118/B119/B120 共用的生产对齐 runner:
|
||||
// 在 newDevAppTestRoot 基础上补齐生产根命令缺的全局 --jq/--fields flag 与
|
||||
// SilenceUsage=true(对齐 internal/app/root.go bindPersistentFlags + 根命令
|
||||
// SilenceUsage),并注入--json 简写布尔。这样 --jq/--fields 可用、错误路径
|
||||
// 不把 usage 打到 stdout(管道安全断言的前提)。
|
||||
func runDevAppFamilyProdAligned(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
// 复用与生产一致的 ResultStore/PostRun emitter,再补齐其余全局 flag。
|
||||
root := newDevAppTestRoot(runner)
|
||||
root.SilenceUsage = true
|
||||
root.SilenceErrors = true
|
||||
root.PersistentFlags().String("fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
root.PersistentFlags().String("jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
|
||||
root.PersistentFlags().Bool("json", false, "以 JSON 输出")
|
||||
return runRootBuffered(t, root, args...)
|
||||
}
|
||||
|
||||
// runRootBuffered 在 stdout/stderr 分流下执行任意 root,返回两路缓冲与错误。
|
||||
func runRootBuffered(t *testing.T, root *cobra.Command, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
var out, errBuf bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errBuf)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return &out, &errBuf, err
|
||||
}
|
||||
|
||||
// TestDevPipelineSafetyStdoutJSONParseable 是队列 B118 的管道安全断言(成功
|
||||
// 路径):stdout 必须恰为一个可 JSON 解析的完整信封,且默认 json 输出与
|
||||
// --jq 提取同一字段等价(jq 等价断言)。这保证 `dws ... | jq .` 管道不会因
|
||||
// 日志/人读杂音混入 stdout 而解析失败。
|
||||
func TestDevPipelineSafetyStdoutJSONParseable(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
|
||||
// 成功叶:stdout 恰为一个 JSON 信封(无日志字节污染)。
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout is not parseable as a single JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["outcome"] != "success" {
|
||||
t.Fatalf("envelope = %#v", env)
|
||||
}
|
||||
|
||||
// jq 等价:--jq .data.name 必须与信封 data.name 一致。
|
||||
jqOut, jqErr, err := runDevAppFamilyProdAligned(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--jq", ".data.name")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(--jq) error = %v\nstdout:\n%s\nstderr:\n%s", err, jqOut.String(), jqErr.String())
|
||||
}
|
||||
if strings.TrimSpace(jqOut.String()) != `"DemoApp"` {
|
||||
t.Fatalf("--jq .data.name = %q, want \"DemoApp\"", jqOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevPipelineSafetyFailureStdoutZeroBytes 是队列 B118 的失败路径管道安全
|
||||
// 断言:参数校验失败错误走 apperrors 通道(不进信封),stdout 必须严格零
|
||||
// 字节——`dws ... | jq .` 在失败时不应把错误细节混进 stdout 数据流。
|
||||
// (失败细节由生产 Execute 的 printExecutionError 落 stderr——本测试聚焦
|
||||
// stdout 数据通道纯净性,与轮11-B0 族错误路径口径一致。)
|
||||
func TestDevPipelineSafetyFailureStdoutZeroBytes(t *testing.T) {
|
||||
out, _, err := runDevAppFamilyProdAligned(t, &captureRunner{},
|
||||
"dev", "app", "get") // 缺 --unified-app-id / --app-key
|
||||
if err == nil || !strings.Contains(err.Error(), "请传入 --unified-app-id 或 --app-key") {
|
||||
t.Fatalf("Execute() error = %v, want locator validation", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("validation failure must keep stdout zero bytes (pipeline safety): got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevPipelineSafetyFailureEnvelopeJSONOnStderr 是队列 B118 的失败信封
|
||||
// 管道安全断言:显式信封装失败(如 Confirmation 门禁)经错误通道落 stderr 且
|
||||
// 为完整 JSON 信封,stdout 零字节。错误信封不得被 jq/format 过滤为空。
|
||||
func TestDevPipelineSafetyFailureEnvelopeJSONOnStderr(t *testing.T) {
|
||||
// 写叶子真实执行(无 --dry-run、无 --yes)触发 confirmation_required 门禁。
|
||||
// 信封经 writeEnvelope → Emitter 落 stderr(stdout 严格零字节)。
|
||||
out, _, err := runDevAppFamilyProdAligned(t,
|
||||
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "x"}),
|
||||
"dev", "app", "update", "--unified-app-id", "u-1", "--name", "x")
|
||||
if err == nil {
|
||||
t.Fatalf("update without --yes must fail, got nil")
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("confirmation failure must keep stdout zero bytes, got %q", out.String())
|
||||
}
|
||||
// 错误路径完整 JSON 信封落在 stderr(结构化错误通道)。面板级观察:
|
||||
// 此处直接复用 writeEnvelope 的 Emitter 语义做单测视角——先验证
|
||||
// writeEnvelope 对失败信封的 stderr 输出为 JSON,再端到端断言 stdout 零字节。
|
||||
cmd, cmdOut, cmdErr := newPipelineLeafCmd("json")
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(cmdOut)
|
||||
cmd.SetErr(cmdErr)
|
||||
failEnv := output.NewFailureEnvelope(&output.ErrorInfo{Type: "permission", Subtype: "confirmation_required", Message: "need confirmation"})
|
||||
if werr := writeEnvelope(cmd, failEnv); werr != nil {
|
||||
t.Fatalf("writeEnvelope failure envelope error = %v", werr)
|
||||
}
|
||||
if cmdOut.Len() != 0 {
|
||||
t.Fatalf("writeEnvelope failure must keep stdout zero bytes, got %q", cmdOut.String())
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
if jerr := json.Unmarshal(cmdErr.Bytes(), &env); jerr != nil {
|
||||
t.Fatalf("stderr is not a JSON envelope: %v\n%s", jerr, cmdErr.String())
|
||||
}
|
||||
if env.OK || env.Outcome != "failure" {
|
||||
t.Fatalf("stderr envelope ok/outcome = %v/%q, want false/failure", env.OK, env.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = output.FormatJSON
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// devSchemaAllDump 是 dev 子树的 schema --all 装配 dump 形态(只读投影)。
|
||||
// 由 AssembleSchemaRegistryFromBound(bind 后装配)→ ToPayload(--all dump)
|
||||
// 生成,与生产 registerSchemaSourceRoot → ResolveSchemaBuild →
|
||||
// deliverySchemaCatalog 同一条装配语义,仅命令表面收敛为 dev 子树。
|
||||
type devSchemaAllDump struct {
|
||||
Products []struct {
|
||||
ID string `json:"id"`
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
Name string `json:"name"`
|
||||
CLIPath string `json:"cli_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
|
||||
// assembleDevSchemaAllDump 在 dev 测试 root 上走 identity 收集 → bind → 装配 →
|
||||
// --all dump 的旁路,返回轻量解析结构。只读,不改装配。
|
||||
func assembleDevSchemaAllDump(t *testing.T) devSchemaAllDump {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry(dev): %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry(dev): %v", err)
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistryFromBound(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistryFromBound(dev): %v", err)
|
||||
}
|
||||
payload, err := registry.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaRegistry.ToPayload(dev): %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dev schema --all dump: %v", err)
|
||||
}
|
||||
var dump devSchemaAllDump
|
||||
if err := json.Unmarshal(raw, &dump); err != nil {
|
||||
t.Fatalf("dev schema --all dump is not parseable: %v\n%s", err, raw)
|
||||
}
|
||||
return dump
|
||||
}
|
||||
|
||||
// TestDevSchemaAllDumpToolsParseable 是队列 B192 的 dev tool 可解析旁路断言:
|
||||
// dev 子树 schema --all 装配 dump 中每个 tool 必须可解析(canonical_path 与
|
||||
// name 非空、cli_path 非空),且代表 dev 域核心能力的 tool 必须存在。任何
|
||||
// 装配后在 dump 中泄漏「空 canonical / 空 name」的 dev tool 都会在此暴露。
|
||||
func TestDevSchemaAllDumpToolsParseable(t *testing.T) {
|
||||
dump := assembleDevSchemaAllDump(t)
|
||||
|
||||
var devProduct *struct {
|
||||
ID string `json:"id"`
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
Name string `json:"name"`
|
||||
CLIPath string `json:"cli_path"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
for i := range dump.Products {
|
||||
if dump.Products[i].ID == "dev" {
|
||||
devProduct = &dump.Products[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if devProduct == nil {
|
||||
t.Fatalf("dev schema --all dump missing 'dev' product: got products %#v", dump.Products)
|
||||
}
|
||||
if len(devProduct.Tools) == 0 {
|
||||
t.Fatal("dev schema --all dump has 0 tools")
|
||||
}
|
||||
|
||||
canonicals := make(map[string]bool)
|
||||
for _, tool := range devProduct.Tools {
|
||||
toolName := tool.Name
|
||||
t.Run(toolName, func(t *testing.T) {
|
||||
if strings.TrimSpace(tool.CanonicalPath) == "" {
|
||||
t.Fatalf("dev tool %q has empty canonical_path (not parseable)", toolName)
|
||||
}
|
||||
if strings.TrimSpace(tool.Name) == "" {
|
||||
t.Fatalf("dev tool has empty name (canonical_path=%q)", tool.CanonicalPath)
|
||||
}
|
||||
if strings.TrimSpace(tool.CLIPath) == "" {
|
||||
t.Fatalf("dev tool %q has empty cli_path", toolName)
|
||||
}
|
||||
canonicals[tool.CanonicalPath] = true
|
||||
})
|
||||
}
|
||||
|
||||
// dev 域核心能力必须出现在装配 dump(可解析旁路的目标集合)。
|
||||
for _, want := range []string{"dev.get_dev_app", "dev.list_dev_app", "dev.list_dev_app_versions", "dev.list_dev_app_events", "dev.list_dev_app_permissions", "dev.list_dev_app_members"} {
|
||||
if !canonicals[want] {
|
||||
t.Errorf("dev schema --all dump missing canonical tool %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevSchemaAllDumpCountStable 是队列 B192 的补充观察断言:dev 子树装配 dump
|
||||
// 的 tool 数量稳定(当前 37;统一框架已收编三条原 exclusion),作为回归基线快照。数量变化提示 dev 命令表面
|
||||
// 变更,需复核装配。
|
||||
func TestDevSchemaAllDumpCountStable(t *testing.T) {
|
||||
dump := assembleDevSchemaAllDump(t)
|
||||
for _, p := range dump.Products {
|
||||
if p.ID == "dev" {
|
||||
if len(p.Tools) != 37 {
|
||||
t.Fatalf("dev schema --all tool count = %d, want 37 (baseline)", len(p.Tools))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("dev product not found in schema --all dump")
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDevAppSharedResultMapperClassifiesServiceOutcomes(t *testing.T) {
|
||||
t.Run("normalized success", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{
|
||||
"content": map[string]any{"success": true, "result": map[string]any{"id": "a"}},
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := env.Data.(map[string]any)
|
||||
if env.Outcome != output.OutcomeSuccess || data["id"] != "a" {
|
||||
t.Fatalf("success envelope=%+v data=%#v", env, env.Data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non boolean success fails closed", func(t *testing.T) {
|
||||
for _, invalid := range []any{"false", 0, map[string]any{"value": false}} {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{"success": invalid}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeFailure || env.Error == nil ||
|
||||
env.Error.Subtype != "invalid_success_type" || env.Error.Hint == "" {
|
||||
t.Fatalf("invalid success=%#v envelope=%+v", invalid, env)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pending approval", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{
|
||||
"versionStatus": "AUDIT", "versionId": "v1", "unifiedAppId": "u1",
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomePending || env.Meta == nil || env.Meta.Operation == nil || env.Meta.Operation.NextCommand == "" {
|
||||
t.Fatalf("pending envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approval selection uses tool normalization", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload(devAppVersionPublishTool, map[string]any{
|
||||
"approvalMode": "SELECT_APPROVER",
|
||||
"unifiedAppId": "u1",
|
||||
"versionId": "v1",
|
||||
"approvalCandidates": []any{
|
||||
map[string]any{"userId": "user-1", "name": "Alice"},
|
||||
},
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomePending || env.Meta == nil || env.Meta.Operation == nil {
|
||||
t.Fatalf("approval envelope=%+v", env)
|
||||
}
|
||||
if env.Meta.Operation.State != "waiting_for_approver_selection" || env.Meta.Operation.NextCommand == "" {
|
||||
t.Fatalf("approval operation=%+v", env.Meta.Operation)
|
||||
}
|
||||
if strings.Contains(env.Meta.Operation.NextCommand, "--yes") {
|
||||
t.Fatalf("approval next_command bypasses confirmation: %q", env.Meta.Operation.NextCommand)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{
|
||||
"items": []any{}, "hasMore": true, "nextCursor": "next",
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Pagination == nil || env.Meta.Pagination.EndpointExhausted || env.Meta.Pagination.NextToken != "next" {
|
||||
t.Fatalf("pagination envelope=%+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("pagination data type=%T, want map[string]any", env.Data)
|
||||
}
|
||||
if _, exists := data["hasMore"]; exists {
|
||||
t.Fatalf("business data must not retain hasMore: %#v", data)
|
||||
}
|
||||
if _, exists := data["nextCursor"]; exists {
|
||||
t.Fatalf("business data must not retain nextCursor: %#v", data)
|
||||
}
|
||||
if _, exists := data["items"]; !exists {
|
||||
t.Fatalf("business data lost items while extracting pagination: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
for name, payload := range map[string]map[string]any{
|
||||
"missing cursor": {"items": []any{}, "hasMore": true},
|
||||
"wrong flag type": {"items": []any{}, "hasMore": "false"},
|
||||
} {
|
||||
t.Run("pagination inconsistent "+name, func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", payload, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeFailure || env.Error == nil || env.Error.Subtype != "pagination_inconsistent" {
|
||||
t.Fatalf("pagination envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("terminal cursor is not resumable", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{
|
||||
"items": []any{}, "hasMore": false, "nextCursor": "terminal-cursor",
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeSuccess || env.Meta == nil || env.Meta.Pagination == nil ||
|
||||
!env.Meta.Pagination.EndpointExhausted || env.Meta.Pagination.NextToken != "" {
|
||||
t.Fatalf("terminal pagination envelope=%+v", env)
|
||||
}
|
||||
data, _ := env.Data.(map[string]any)
|
||||
if _, exists := data["nextCursor"]; exists {
|
||||
t.Fatalf("terminal data leaked cursor: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tool := range []string{devAppListTool, devAppPermissionListTool, devAppEventListTool, devAppVersionListTool} {
|
||||
t.Run("declared pagination missing "+tool, func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload(tool, map[string]any{"items": []any{}}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeFailure || env.Error == nil ||
|
||||
env.Error.Subtype != "pagination_inconsistent" {
|
||||
t.Fatalf("missing pagination envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run preview needs no server pagination "+tool, func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload(tool, map[string]any{
|
||||
"invocation": map[string]any{"tool": tool},
|
||||
}, true)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeSuccess || !env.DryRun || env.Meta != nil {
|
||||
t.Fatalf("dry-run preview envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial", func(t *testing.T) {
|
||||
started := true
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{
|
||||
"multiProfile": true,
|
||||
"profiles": []any{
|
||||
map[string]any{"selector": "a", "ok": true, "result": map[string]any{"id": "r1"}},
|
||||
map[string]any{"selector": "b", "ok": false, "error": map[string]any{
|
||||
"category": "network", "reason": "timeout", "message": "bad",
|
||||
"stage": "response", "execution_started": started,
|
||||
}},
|
||||
},
|
||||
}, false)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomePartialFailure || result.ExitCode() != 7 {
|
||||
t.Fatalf("partial outcome=%s rc=%d", env.Outcome, result.ExitCode())
|
||||
}
|
||||
partial, ok := env.Data.(*output.PartialData)
|
||||
if !ok || len(partial.Failed) != 1 || partial.Failed[0].Error == nil ||
|
||||
partial.Failed[0].Error.Type != "api" || partial.Failed[0].Error.Subtype != "timeout" ||
|
||||
partial.Failed[0].Error.ExecutionStarted == nil || !*partial.Failed[0].Error.ExecutionStarted {
|
||||
t.Fatalf("partial error projection=%#v", env.Data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run is completed preview", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{"tool": "x"}, true)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeSuccess || !env.DryRun {
|
||||
t.Fatalf("dry-run envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run still enforces pagination contract", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload(devAppListTool, map[string]any{
|
||||
"items": []any{}, "hasMore": false,
|
||||
}, true)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeSuccess || !env.DryRun || env.Meta == nil ||
|
||||
env.Meta.Pagination == nil || !env.Meta.Pagination.EndpointExhausted {
|
||||
t.Fatalf("dry-run pagination envelope=%+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("dry-run pagination data type=%T", env.Data)
|
||||
}
|
||||
if _, exists := data["hasMore"]; exists {
|
||||
t.Fatalf("dry-run data leaked hasMore: %#v", data)
|
||||
}
|
||||
if _, exists := data["nextCursor"]; exists {
|
||||
t.Fatalf("dry-run data leaked nextCursor: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run still fails closed on invalid success type", func(t *testing.T) {
|
||||
result := DevAppCommandResultFromPayload("", map[string]any{"success": "false"}, true)
|
||||
env, err := output.EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Outcome != output.OutcomeFailure || env.Error == nil || env.Error.Subtype != "invalid_success_type" {
|
||||
t.Fatalf("dry-run invalid success envelope=%+v", env)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevAppRecoveryCommandsDoNotBypassConfirmation(t *testing.T) {
|
||||
steps := append(devAppRobotPublishSteps("u1"), devAppRobotRetryStep("task1", true))
|
||||
approval := map[string]any{
|
||||
"approvalMode": "SELECT_APPROVER",
|
||||
"unifiedAppId": "u1",
|
||||
"versionId": "v1",
|
||||
"approvalCandidates": []any{
|
||||
map[string]any{"userId": "user-1", "name": "Alice"},
|
||||
},
|
||||
}
|
||||
normalizeDevAppVersionApproval(approval)
|
||||
steps = append(steps, approval["nextSteps"].([]map[string]any)...)
|
||||
for _, step := range steps {
|
||||
for _, key := range []string{"command", "dryRunCommand"} {
|
||||
command, _ := step[key].(string)
|
||||
for _, field := range strings.Fields(command) {
|
||||
if field == "--yes" || strings.HasPrefix(field, "--yes=") {
|
||||
t.Fatalf("step %v publishes confirmation bypass in %s: %q", step["id"], key, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkDevAppMapperBoundaryMatrix(t *testing.T) {
|
||||
if got := devAppDataWithoutPagination("scalar"); got != "scalar" {
|
||||
t.Fatalf("non-object data = %#v, want scalar preserved", got)
|
||||
}
|
||||
for name, payload := range map[string]any{
|
||||
"scalar": "value",
|
||||
"bad cursor": map[string]any{"nextCursor": 1},
|
||||
"cursor only": map[string]any{"nextCursor": "next"},
|
||||
"empty cursor": map[string]any{"nextCursor": ""},
|
||||
"exhausted": map[string]any{"hasMore": false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) { _, _ = devAppPaginationMeta(payload) })
|
||||
}
|
||||
|
||||
if devAppMultiProfileResult(map[string]any{}) != nil || devAppMultiProfileResult(map[string]any{"multiProfile": true}) != nil {
|
||||
t.Fatal("non-profile payload classified")
|
||||
}
|
||||
allSuccess := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{map[string]any{"ok": true}}})
|
||||
if allSuccess != nil {
|
||||
t.Fatalf("all-success multi profile=%v", allSuccess)
|
||||
}
|
||||
allFailed := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"ok": false, "error": map[string]any{
|
||||
"type": "authorization", "message": "denied", "retryable": true, "execution_started": false,
|
||||
"actions": []any{"retry", 1}, "details": map[string]any{"scope": "x"}, "requestId": "req", "traceId": "trace",
|
||||
}},
|
||||
"malformed",
|
||||
}})
|
||||
allFailedEnv, err := output.EnvelopeFromResult(allFailed)
|
||||
if err != nil || allFailedEnv.Outcome != output.OutcomeFailure || allFailedEnv.Error == nil || allFailedEnv.Error.Details == nil {
|
||||
t.Fatalf("all-failed=%+v err=%v", allFailedEnv, err)
|
||||
}
|
||||
mixed := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"selector": "ok", "ok": true},
|
||||
map[string]any{"selector": "bad", "ok": false, "error": map[string]any{"category": "forbidden", "actions": []string{"fix"}, "code": "E"}},
|
||||
123,
|
||||
}})
|
||||
if env, err := output.EnvelopeFromResult(mixed); err != nil || env.Outcome != output.OutcomePartialFailure {
|
||||
t.Fatalf("mixed=%+v err=%v", env, err)
|
||||
}
|
||||
duplicate := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"selector": "same", "ok": true},
|
||||
map[string]any{"selector": "same", "ok": false},
|
||||
}})
|
||||
if env, err := output.EnvelopeFromResult(duplicate); err != nil || env.Outcome != output.OutcomeFailure || env.Error.Type != "internal" {
|
||||
t.Fatalf("duplicate=%+v err=%v", env, err)
|
||||
}
|
||||
|
||||
for _, payload := range []map[string]any{
|
||||
{"success": false, "message": "no", "errorCode": "E"},
|
||||
{"status": "FAILED", "code": 1},
|
||||
{"status": "OK"},
|
||||
} {
|
||||
_ = devAppFailureResult(payload)
|
||||
}
|
||||
for _, raw := range []string{"validation", "forbidden", "timeout"} {
|
||||
_ = devAppWireErrorType(raw)
|
||||
}
|
||||
|
||||
for _, content := range []map[string]any{
|
||||
{},
|
||||
{"mustAskUser": true},
|
||||
{"status": "WAITING", "taskId": "task"},
|
||||
{"status": "WAITING", "requestId": "req"},
|
||||
{"status": "WAITING", "taskId": "task", "nextSteps": []map[string]any{{"command": "dws next"}}},
|
||||
{"status": "WAITING", "requestId": "req", "nextSteps": []any{map[string]any{"dryRunCommand": "dws preview"}, "bad"}},
|
||||
{"approvalSubmitted": true, "unifiedAppId": "app", "versionId": "v"},
|
||||
} {
|
||||
result := devAppPendingResult(content)
|
||||
if result != nil {
|
||||
_, _ = output.EnvelopeFromResult(result)
|
||||
}
|
||||
}
|
||||
if got := devAppRecoveryCommand(map[string]any{"taskId": "t"}); !bytes.Contains([]byte(got), []byte("robot result")) {
|
||||
t.Fatalf("task recovery=%q", got)
|
||||
}
|
||||
if got := devAppRecoveryCommand(map[string]any{}); got != "" {
|
||||
t.Fatalf("empty recovery=%q", got)
|
||||
}
|
||||
|
||||
if got := devAppEnvelopeData(executor.Result{}); got == nil {
|
||||
t.Fatal("unimplemented result should be preserved")
|
||||
}
|
||||
for _, result := range []executor.Result{
|
||||
{Invocation: executor.Invocation{Implemented: true, Kind: "helper_invocation", DryRun: true}, Response: map[string]any{"content": map[string]any{"preview": true}}},
|
||||
{Invocation: executor.Invocation{Implemented: true, Kind: "helper_invocation"}, Response: map[string]any{"content": map[string]any{"items": []any{}, "hasMore": false}}},
|
||||
} {
|
||||
cmd := &cobra.Command{Use: "legacy-envelope"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
if err := writeDevAppEnvelope(cmd, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
legacy := &cobra.Command{Use: "legacy"}
|
||||
legacy.SetContext(context.Background())
|
||||
legacy.SetOut(&bytes.Buffer{})
|
||||
if err := writeDevRolloutResult(legacy, output.Success(nil), output.NewSuccessEnvelope(nil), output.FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dual := &cobra.Command{Use: "dual"}
|
||||
dual.SetContext(context.Background())
|
||||
dual.SetOut(&bytes.Buffer{})
|
||||
output.SetCommandRollout(dual, output.RolloutDualValidate)
|
||||
if err := writeDevRolloutResult(dual, output.Pending(nil, nil), output.NewSuccessEnvelope(nil), output.FormatJSON); err == nil {
|
||||
t.Fatal("dual validate accepted malformed shadow result")
|
||||
}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
active := &cobra.Command{Use: "active"}
|
||||
active.SetContext(ctx)
|
||||
output.SetCommandRollout(active, output.RolloutUnifiedActive)
|
||||
if err := writeDevRolloutResult(active, output.Success(nil), nil, output.FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// devAppTableViewCase 是 B122~B126 的 table 视图用例:每条给出可执行 argv、
|
||||
// list 载荷(items 数组)与期望的列头集合。断言 -f table 输出去包裹信封
|
||||
// 外壳、渲染数据列表为表(列头存在、行值存在),且 enumerate 载荷的 count
|
||||
// 人读摘要落 stderr 或视图。B122~B126 对应 dev app 的 list 族:
|
||||
//
|
||||
// B122 dev app list (应用列表)
|
||||
// B123 dev app version list (版本列表)
|
||||
// B124 dev app event list (事件列表)
|
||||
// B125 dev app permission list (权限列表)
|
||||
// B126 dev app member list (成员列表)
|
||||
func TestDevAppTableViewColumns(t *testing.T) {
|
||||
cases := []struct {
|
||||
batch string
|
||||
args []string
|
||||
items []any
|
||||
headers []string // table 列头(rowsFromSlice 按 key 排序)
|
||||
wantVal string // 必须出现在某行的业务值
|
||||
}{
|
||||
{
|
||||
batch: "B122-app-list",
|
||||
args: []string{"dev", "app", "list", "--name", "DemoApp"},
|
||||
items: []any{
|
||||
map[string]any{"unifiedAppId": "u-1", "name": "DemoApp", "appStatus": "ENABLED"},
|
||||
map[string]any{"unifiedAppId": "u-2", "name": "Another", "appStatus": "DISABLED"},
|
||||
},
|
||||
headers: []string{"appStatus", "name", "unifiedAppId"},
|
||||
wantVal: "Another",
|
||||
},
|
||||
{
|
||||
batch: "B123-version-list",
|
||||
args: []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"},
|
||||
items: []any{
|
||||
map[string]any{"versionId": "v-1", "version": "1.0.0", "status": "ONLINE"},
|
||||
map[string]any{"versionId": "v-2", "version": "1.0.1", "status": "DRAFT"},
|
||||
},
|
||||
headers: []string{"status", "version", "versionId"},
|
||||
wantVal: "1.0.1",
|
||||
},
|
||||
{
|
||||
batch: "B124-event-list",
|
||||
args: []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"},
|
||||
items: []any{
|
||||
map[string]any{"eventCode": "bpms_task_change", "eventName": "审批状态变更"},
|
||||
map[string]any{"eventCode": "contact_change", "eventName": "通讯录变更"},
|
||||
},
|
||||
headers: []string{"eventCode", "eventName"},
|
||||
wantVal: "bpms_task_change",
|
||||
},
|
||||
{
|
||||
batch: "B125-permission-list",
|
||||
args: []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"},
|
||||
items: []any{
|
||||
map[string]any{"scopeValue": "Contact.User.mobile", "scopeName": "手机号码", "authStatus": "AUTHED"},
|
||||
map[string]any{"scopeValue": "Contact.User.read", "scopeName": "通讯录读取", "authStatus": "UNAUTHED"},
|
||||
},
|
||||
headers: []string{"authStatus", "scopeName", "scopeValue"},
|
||||
wantVal: "Contact.User.read",
|
||||
},
|
||||
{
|
||||
batch: "B126-member-list",
|
||||
args: []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"},
|
||||
items: []any{
|
||||
map[string]any{"userId": "user-1", "memberType": "DEVELOPER", "nickname": "张三"},
|
||||
map[string]any{"userId": "user-2", "memberType": "ADMIN", "nickname": "李四"},
|
||||
},
|
||||
headers: []string{"memberType", "nickname", "userId"},
|
||||
wantVal: "李四",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.batch, func(t *testing.T) {
|
||||
args := append([]string{}, tc.args...)
|
||||
args = append(args, "--format", "table")
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"items": tc.items}),
|
||||
args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
// 信封外壳不得出现。
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`, `"items":`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("-f table leaked envelope/wrapper key %s:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
// 列头必须出现(rowsFromSlice 对 map 元素按 key 排序作表头)。
|
||||
for _, h := range tc.headers {
|
||||
if !strings.Contains(s, h) {
|
||||
t.Fatalf("-f table missing column header %q:\n%s", h, s)
|
||||
}
|
||||
}
|
||||
// 至少一行业务值出现。
|
||||
if !strings.Contains(s, tc.wantVal) {
|
||||
t.Fatalf("-f table missing row value %q:\n%s", tc.wantVal, s)
|
||||
}
|
||||
_ = errBuf
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppTableViewEmptyListIsValidTable 是 B122~B126 的空列表 table 视图
|
||||
// 断言(AC-06 空态合法载荷):data:[] 空数组在 -f table 下渲染为空表("value"
|
||||
// 单列头或空行),不报错、不输出 null、信封外壳不泄漏。
|
||||
func TestDevAppTableViewEmptyListIsValidTable(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"items": []any{}}),
|
||||
"dev", "app", "list", "--name", "DemoApp", "--format", "table")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if strings.Contains(s, "null") {
|
||||
t.Fatalf("-f table empty list must not emit null:\n%s", s)
|
||||
}
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("-f table empty list leaked envelope key %s:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-37
@@ -12,14 +12,26 @@ import (
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDevAppTestRoot(runner executor.Runner) *cobra.Command {
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
DisableAutoGenTag: true,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.ValidateUnifiedFormat(cmd)
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(cmd)
|
||||
return err
|
||||
},
|
||||
}
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().Bool("dry-run", false, "dry run")
|
||||
root.PersistentFlags().Bool("yes", false, "yes")
|
||||
root.PersistentFlags().String("format", "json", "format")
|
||||
@@ -27,15 +39,64 @@ func newDevAppTestRoot(runner executor.Runner) *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
func prepareUnifiedTestCommand(cmd *cobra.Command) *cobra.Command {
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
cmd.Flags().String("format", "json", "format")
|
||||
}
|
||||
if cmd.Flags().Lookup("dry-run") == nil {
|
||||
cmd.Flags().Bool("dry-run", false, "dry run")
|
||||
}
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
cmd.Flags().Bool("yes", false, "yes")
|
||||
}
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
type devAppResponseRunner struct {
|
||||
last executor.Invocation
|
||||
response map[string]any
|
||||
last executor.Invocation
|
||||
response map[string]any
|
||||
preserveMissingPagination bool
|
||||
}
|
||||
|
||||
func (r *devAppResponseRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.last = invocation
|
||||
invocation.Implemented = true
|
||||
return executor.Result{Invocation: invocation, Response: r.response}, nil
|
||||
response := r.response
|
||||
if !r.preserveMissingPagination && devAppToolRequiresPagination(invocation.Tool) {
|
||||
if service, ok := response["content"].(map[string]any); ok {
|
||||
if result, ok := service["result"].(map[string]any); ok {
|
||||
_, hasFlag := result["hasMore"]
|
||||
_, hasCursor := result["nextCursor"]
|
||||
if !hasFlag && !hasCursor {
|
||||
resultCopy := make(map[string]any, len(result)+1)
|
||||
for key, value := range result {
|
||||
resultCopy[key] = value
|
||||
}
|
||||
resultCopy["hasMore"] = false
|
||||
serviceCopy := make(map[string]any, len(service))
|
||||
for key, value := range service {
|
||||
serviceCopy[key] = value
|
||||
}
|
||||
serviceCopy["result"] = resultCopy
|
||||
responseCopy := make(map[string]any, len(response))
|
||||
for key, value := range response {
|
||||
responseCopy[key] = value
|
||||
}
|
||||
responseCopy["content"] = serviceCopy
|
||||
response = responseCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
func TestDevAppMemberCommandsBuildToolParams(t *testing.T) {
|
||||
@@ -355,7 +416,7 @@ func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) {
|
||||
|
||||
func TestDevAppListBuildsListByConditionParams(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
root := newDevAppCommand(runner)
|
||||
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
@@ -381,7 +442,7 @@ func TestDevAppListBuildsListByConditionParams(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDevAppGetBuildsDetailParams(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
root := newDevAppCommand(runner)
|
||||
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
@@ -401,7 +462,7 @@ func TestCrossPlatformCoverageDevAppGetBuildsDetailParams(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDevAppGetBuildsDetailParamsByAppKey(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
root := newDevAppCommand(runner)
|
||||
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
@@ -421,7 +482,7 @@ func TestCrossPlatformCoverageDevAppGetBuildsDetailParamsByAppKey(t *testing.T)
|
||||
|
||||
func TestCrossPlatformCoverageDevAppGetPrefersUnifiedAppIDWhenBothPresent(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
root := newDevAppCommand(runner)
|
||||
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
@@ -1128,10 +1189,7 @@ func TestDevAppUnwrapsSuccessfulServiceResult(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var rendered map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &rendered); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
rendered := unwrapDevAppEnvelopeData(t, out.Bytes())
|
||||
if _, ok := rendered["success"]; ok {
|
||||
t.Fatalf("output kept ServiceResult wrapper: %#v", rendered)
|
||||
}
|
||||
@@ -1180,10 +1238,7 @@ func TestDevAppVersionCheckApprovalPreservesApprovalCandidateNames(t *testing.T)
|
||||
if precheckOnly, _ := runner.last.Params["precheckOnly"].(bool); !precheckOnly {
|
||||
t.Fatalf("precheckOnly = %#v, want true", runner.last.Params["precheckOnly"])
|
||||
}
|
||||
var rendered map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &rendered); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
rendered := unwrapDevAppEnvelopeData(t, out.Bytes())
|
||||
candidates, ok := rendered["approvalCandidates"].([]any)
|
||||
if !ok || len(candidates) != 2 {
|
||||
t.Fatalf("approvalCandidates = %#v, want two candidates", rendered["approvalCandidates"])
|
||||
@@ -1465,31 +1520,44 @@ func TestDevAppRobotResultSuccessWithoutUnifiedAppIDBlocksForUserInput(t *testin
|
||||
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
|
||||
}
|
||||
|
||||
func TestDevAppRobotResultFailAndExpiredAddRetrySteps(t *testing.T) {
|
||||
func TestDevAppRobotResultFailAndExpiredAreTerminalFailures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
wantTaskIDFlag bool
|
||||
name string
|
||||
status string
|
||||
}{
|
||||
{name: "fail reuses task id", status: "FAIL", wantTaskIDFlag: true},
|
||||
{name: "expired resubmits without task id", status: "EXPIRED", wantTaskIDFlag: false},
|
||||
{name: "fail", status: "FAIL"},
|
||||
{name: "expired", status: "EXPIRED"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rendered := runDevAppRobotResultOutput(t, map[string]any{
|
||||
"status": tc.status,
|
||||
"taskId": "t-retry",
|
||||
})
|
||||
steps := devAppRenderedSteps(t, rendered)
|
||||
retry := devAppStepByID(t, steps, "retry_robot_submit")
|
||||
command, _ := retry["command"].(string)
|
||||
hasTaskID := strings.Contains(command, "--task-id t-retry")
|
||||
if hasTaskID != tc.wantTaskIDFlag {
|
||||
t.Fatalf("retry command = %q, has task id %v, want %v", command, hasTaskID, tc.wantTaskIDFlag)
|
||||
runner := &devAppResponseRunner{response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"status": tc.status, "taskId": "t-retry"},
|
||||
},
|
||||
}}
|
||||
root := newDevAppTestRoot(runner)
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "app", "robot", "result", "--task-id", "t-1", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if dryRun, _ := retry["dryRunCommand"].(string); !strings.Contains(dryRun, "--dry-run") {
|
||||
t.Fatalf("retry dryRunCommand = %q, want --dry-run", dryRun)
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if env.OK || env.Outcome != "failure" || env.Error == nil || env.Error.Type != "api" {
|
||||
t.Fatalf("terminal %s envelope = %+v, want API failure", tc.status, env)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1516,11 +1584,50 @@ func runDevAppRobotResultOutput(t *testing.T, result map[string]any) map[string]
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var rendered map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &rendered); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Data map[string]any `json:"data"`
|
||||
Meta *struct {
|
||||
Operation *struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
NextCommand string `json:"next_command"`
|
||||
} `json:"operation"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
return rendered
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.Bytes())
|
||||
}
|
||||
if !env.OK || env.Outcome != "pending" || env.Data == nil || env.Meta == nil || env.Meta.Operation == nil {
|
||||
t.Fatalf("async envelope = %+v, want pending with data and operation", env)
|
||||
}
|
||||
if env.Meta.Operation.ID == "" || env.Meta.Operation.State == "" || env.Meta.Operation.NextCommand == "" {
|
||||
t.Fatalf("pending operation = %+v, want id/state/next_command", env.Meta.Operation)
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
// unwrapDevAppEnvelopeData 解析 dev app 树统一信封输出并返回 data 层
|
||||
// (统一输出 dev 域试点,队列 Phase F):既有断言直接消费裸载荷,
|
||||
// 信封化后先校验 ok/outcome,再返回 data(业务载荷形状不变)。
|
||||
func unwrapDevAppEnvelopeData(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, raw)
|
||||
}
|
||||
if !env.OK || env.Outcome != "success" {
|
||||
t.Fatalf("envelope ok/outcome = %v/%q, want true/success: %s", env.OK, env.Outcome, raw)
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("envelope data is nil: %s", raw)
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
func devAppRenderedSteps(t *testing.T, rendered map[string]any) []any {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDevAppAllLeavesStdoutZeroLogBytes 是队列 B119 的 stdout 零日志字节总
|
||||
// 断言:dev app 全树 32 叶子的 stdout 必须恰为一个 JSON 信封(json解包严格
|
||||
// 成功),不得混入任何日志/人读杂音字节。读叶子走成功信封、写叶子走
|
||||
// --dry-run success 信封。任何把日志打到 stdout 的叶子都会在此暴露
|
||||
// (json.Unmarshal 对前后冗余字节报错)。
|
||||
func TestDevAppAllLeavesStdoutZeroLogBytes(t *testing.T) {
|
||||
for _, entry := range devAppEnvelopeRegressionChecklist() {
|
||||
t.Run(entry.path, func(t *testing.T) {
|
||||
args := append([]string{}, entry.args...)
|
||||
if entry.write {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}),
|
||||
args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
// 严格:整个 stdout 缓冲必须能被 json 完整消费(无头尾杂音)。
|
||||
dec := json.NewDecoder(out)
|
||||
var env regressionEnvelope
|
||||
if derr := dec.Decode(&env); derr != nil {
|
||||
t.Fatalf("stdout is not a single JSON envelope (log bytes leaked?): %v\n%s", derr, out.String())
|
||||
}
|
||||
if dec.More() {
|
||||
t.Fatalf("stdout has trailing content after envelope (log bytes leaked?): %s", out.String())
|
||||
}
|
||||
if env.OK == nil || !*env.OK {
|
||||
t.Fatalf("envelope ok missing/false: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFieldsProjectionCombinesWithEnvelope 是队列 B120 的 --fields 过滤
|
||||
// 与信封组合断言:json 模式下 --fields 只投影 data 内业务字段,稳定的
|
||||
// ok/outcome/data 信封仍必须存在,供 Agent 判断结果状态。
|
||||
func TestDevAppFieldsProjectionCombinesWithEnvelope(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
// --fields name:只保留业务载荷的 name 字段,其余业务字段被剔除。
|
||||
out, errBuf, err := runDevAppFamilyProdAligned(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--fields", "name")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
var envelope regressionEnvelope
|
||||
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("--fields stdout is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if envelope.OK == nil || !*envelope.OK || envelope.Outcome != "success" {
|
||||
t.Fatalf("--fields must preserve the success envelope: %s", out.String())
|
||||
}
|
||||
payload := envelope.Data
|
||||
if payload["name"] != "DemoApp" {
|
||||
t.Fatalf("--fields name = %#v, want the projected business payload", payload)
|
||||
}
|
||||
for _, dropped := range []string{"unifiedAppId", "appStatus"} {
|
||||
if _, has := payload[dropped]; has {
|
||||
t.Fatalf("--fields name must drop business field %q: %#v", dropped, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFieldsTableViewCombines 是队列 B120 的 --fields 与 table 组合:
|
||||
// 非 json 下 --fields 先投影 data 再按 format 渲染(§5.2 联动),table 视图
|
||||
// 只含选定列、不含丢弃字段。
|
||||
func TestDevAppFieldsTableViewCombines(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
out, errBuf, err := runDevAppFamilyProdAligned(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--fields", "name", "--format", "table")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if !strings.Contains(s, "DemoApp") {
|
||||
t.Fatalf("table --fields=name missing value:\n%s", s)
|
||||
}
|
||||
for _, banned := range []string{"ENABLED", "\"appStatus\""} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("table --fields=name must drop non-selected field %q:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDevdocCommand() *cobra.Command {
|
||||
@@ -129,7 +129,7 @@ func newDevdocArticleSearchCommand() *cobra.Command {
|
||||
// newDevDocSearchCommand is the `dws dev doc search` surface — same execution
|
||||
// body as devdoc article search, but ContractFinal examples must use the
|
||||
// reviewed primary path for canonical dev.search_open_platform_docs_rag.
|
||||
func newDevDocSearchCommand() *cobra.Command {
|
||||
func newDevDocSearchCommand(_ ...executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [keyword]",
|
||||
Short: "搜索开放平台文档",
|
||||
@@ -191,9 +191,9 @@ func newDevDocSearchCommand() *cobra.Command {
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "devdoc", RPCName: "search_open_platform_docs"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "通过 dev 兼容入口搜索开放平台文档",
|
||||
UseWhen: []string{"明确需要验证或使用 dev doc search 入口时"},
|
||||
AvoidWhen: []string{"常规开放平台文档检索优先使用可用的 devdoc article search"},
|
||||
AgentSummary: "搜索钉钉开放平台官方文档",
|
||||
UseWhen: []string{"需要查询开放平台 API、参数、权限点或错误码时"},
|
||||
AvoidWhen: []string{"已有确定义务命令可直接完成任务时"},
|
||||
Examples: []string{"dws dev doc search --query \"MCP\" --size 10"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFramework2HelperOutputEdges(t *testing.T) {
|
||||
if err := writeCommandPayload(nil, map[string]any{"ok": true}); err != nil {
|
||||
t.Fatalf("nil command payload: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "output"}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := writeEnvelope(cmd, output.NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatalf("success envelope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
if err := writeEnvelope(nil, nil); err != nil {
|
||||
t.Fatalf("nil failure envelope: %v", err)
|
||||
}
|
||||
|
||||
f := NewFormatterWithWriters(nil, nil)
|
||||
if f.w != os.Stdout || f.errW != os.Stderr {
|
||||
t.Fatalf("nil writers did not use process defaults")
|
||||
}
|
||||
var nextOut, nextErr bytes.Buffer
|
||||
f.SetWriters(&nextOut, nil)
|
||||
f.SetWriters(nil, &nextErr)
|
||||
if f.w != &nextOut || f.errW != &nextErr {
|
||||
t.Fatalf("SetWriters did not replace each non-nil side")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) {
|
||||
original := deps
|
||||
t.Cleanup(func() { deps = original })
|
||||
|
||||
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"id":"x"}`}}}})
|
||||
data, err := CallMCPToolDataOnServer(nil, "dev", "get_thing", nil)
|
||||
if err != nil || data.(map[string]any)["id"] != "x" {
|
||||
t.Fatalf("decoded data=%#v err=%v", data, err)
|
||||
}
|
||||
|
||||
InitDeps(&coverageErrorCaller{err: errors.New("transport failed")})
|
||||
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
|
||||
t.Fatal("expected transport error")
|
||||
}
|
||||
|
||||
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: " "}}}})
|
||||
data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil)
|
||||
if err != nil || len(data.(map[string]any)) != 0 {
|
||||
t.Fatalf("empty response data=%#v err=%v", data, err)
|
||||
}
|
||||
|
||||
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{"}}}})
|
||||
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
|
||||
t.Fatal("expected invalid JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) {
|
||||
original := deps
|
||||
t.Cleanup(func() { deps = original })
|
||||
caller := &coverageErrorCaller{}
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out = NewFormatterWithWriters(&stdout, &bytes.Buffer{})
|
||||
if err := RenderLegacyMCPText("get_thing", `{"id":"x"}`); err != nil {
|
||||
t.Fatalf("RenderLegacyMCPText: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id": "x"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFramework2LeafResultEdges(t *testing.T) {
|
||||
mustPanic := func(name string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%s: expected panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
resultCall := func(*cobra.Command, string, map[string]any) (output.CommandResult, error) {
|
||||
return output.Success(map[string]any{"ok": true}), nil
|
||||
}
|
||||
mustPanic("metadata result call", func() {
|
||||
DeclareLeafMetadata(&cobra.Command{Use: "meta"}, LeafSpec{ResultCall: resultCall})
|
||||
})
|
||||
mustPanic("two call styles", func() {
|
||||
FromLeafSpec(LeafSpec{Use: "both", Call: func(*cobra.Command, string, map[string]any) error { return nil }, ResultCall: resultCall})
|
||||
})
|
||||
|
||||
cmd := prepareUnifiedTestCommand(NewLeafCommand(LeafSpec{
|
||||
Use: "result", Tool: "get_thing", OutputRollout: output.RolloutUnifiedActive, ResultCall: resultCall,
|
||||
}))
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("ResultCall command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFramework2DevDocRemainsLegacy(t *testing.T) {
|
||||
cmd := newDevDocSearchCommand()
|
||||
if got := output.CommandRollout(cmd); got != output.RolloutLegacyOnly {
|
||||
t.Fatalf("dev doc rollout=%s, want legacy_only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFramework2ConnectLegacyEdges(t *testing.T) {
|
||||
original := connectDaemonDirOverride
|
||||
t.Cleanup(func() { connectDaemonDirOverride = original })
|
||||
|
||||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
stop := newDevAppRobotConnectStopCommand()
|
||||
stop.Flags().Bool("yes", true, "")
|
||||
if err := stop.Flags().Set("robot-client-id", "ding-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stop.RunE(stop, nil); err == nil {
|
||||
t.Fatal("expected daemon directory error")
|
||||
}
|
||||
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
list := newDevAppRobotConnectListCommand(nil)
|
||||
output.SetCommandRollout(list, output.RolloutLegacyOnly)
|
||||
if list.Flags().Lookup("format") == nil {
|
||||
list.Flags().String("format", "table", "")
|
||||
} else if err := list.Flags().Set("format", "table"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
list.SetOut(&stdout)
|
||||
if err := list.RunE(list, nil); err != nil {
|
||||
t.Fatalf("legacy list: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no connectors found") {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
+57
-26
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -268,6 +269,27 @@ func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (st
|
||||
return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args)
|
||||
}
|
||||
|
||||
// CallMCPToolDataOnServer invokes one tool without printing and decodes its
|
||||
// JSON text payload. Framework renderers use this seam so the business request
|
||||
// is executed exactly once and presentation remains a separate step.
|
||||
func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (any, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
|
||||
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
|
||||
func callMCPTool(toolName string, args map[string]any) error {
|
||||
@@ -366,7 +388,6 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]
|
||||
printJSON = deps.Out.PrintJSONUnescaped
|
||||
}
|
||||
|
||||
flagFormat := deps.Caller.Format()
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" {
|
||||
dumpRawToolResponse(serverID, toolName, c.Text)
|
||||
@@ -391,37 +412,47 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 格式输出:解析后使用选定的 printJSON 函数输出
|
||||
if flagFormat == "json" {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(c.Text), &parsed); err == nil {
|
||||
return printJSON(parsed)
|
||||
}
|
||||
}
|
||||
// 特殊处理:开放平台文档搜索结果的表格格式输出
|
||||
if toolName == "search_open_platform_docs" && flagFormat == "table" {
|
||||
if formatted := formatDevdocSearchTable(c.Text); formatted {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 默认:原样输出文本内容。
|
||||
// 当 unescapeHTML=true 时,c.Text 是一段 JSON 字符串,其中 & 已被服务端
|
||||
// 的 JSON 编码器转义为 \u0026。此处先 Unmarshal 还原为 Go 对象,再用
|
||||
// PrintJSONUnescaped 输出,保证 & 不被二次转义。
|
||||
if unescapeHTML {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(c.Text), &parsed); err == nil {
|
||||
return printJSON(parsed)
|
||||
}
|
||||
}
|
||||
deps.Out.PrintRaw(c.Text)
|
||||
return nil
|
||||
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
|
||||
}
|
||||
}
|
||||
// 无 text 类型内容时,将整个 result 对象序列化为 JSON 输出
|
||||
return printJSON(result)
|
||||
}
|
||||
|
||||
// RenderLegacyMCPText renders an already-fetched MCP text response through the
|
||||
// exact legacy formatter. It lets dual validation execute the business request
|
||||
// once, validate a shadow unified result, and still preserve legacy bytes.
|
||||
func RenderLegacyMCPText(toolName, text string) error {
|
||||
return renderLegacyMCPText(toolName, text, false)
|
||||
}
|
||||
|
||||
func renderLegacyMCPText(toolName, text string, unescapeHTML bool) error {
|
||||
printJSON := deps.Out.PrintJSON
|
||||
if unescapeHTML {
|
||||
printJSON = deps.Out.PrintJSONUnescaped
|
||||
}
|
||||
flagFormat := deps.Caller.Format()
|
||||
if flagFormat == "json" {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err == nil {
|
||||
return printJSON(parsed)
|
||||
}
|
||||
}
|
||||
if toolName == "search_open_platform_docs" && flagFormat == "table" {
|
||||
if formatted := formatDevdocSearchTable(text); formatted {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if unescapeHTML {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err == nil {
|
||||
return printJSON(parsed)
|
||||
}
|
||||
}
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpRawToolResponse emits one opt-in lower-layer record for live projection
|
||||
// audits. Responses may contain business data, so normal CLI runs never emit
|
||||
// this line; an auditor must explicitly set DWS_DUMP_RAW.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -34,16 +36,61 @@ type imReadResultCall struct {
|
||||
|
||||
type imReadResultCaller struct {
|
||||
responses map[string]string
|
||||
results map[string]*edition.ToolResult
|
||||
errors map[string]error
|
||||
calls []imReadResultCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName})
|
||||
if err := c.errors[toolName]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result, ok := c.results[toolName]; ok {
|
||||
return result, nil
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.responses[toolName]}}}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadsPreserveToolOperationOnCallerError(t *testing.T) {
|
||||
caller := &imReadResultCaller{errors: map[string]error{
|
||||
"list_conversation_message_v2": errors.New("dial tcp: connection refused"),
|
||||
}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
|
||||
if err == nil {
|
||||
t.Fatalf("chat message list unexpectedly succeeded with output %q", got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("chat message list output = %q, want no success payload", got)
|
||||
}
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Operation != "chat/list_conversation_message_v2" {
|
||||
t.Fatalf("chat message list error = %#v", err)
|
||||
}
|
||||
|
||||
existing := &CLIError{
|
||||
Code: CodeNetworkTimeout,
|
||||
Message: "request already classified",
|
||||
Operation: "chat/existing-operation",
|
||||
}
|
||||
caller = &imReadResultCaller{errors: map[string]error{
|
||||
"list_conversation_message_v2": existing,
|
||||
}}
|
||||
got, err = executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
|
||||
if err != existing {
|
||||
t.Fatalf("chat message list error = %#v, want original %#v", err, existing)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("chat message list output = %q, want no success payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func (*imReadResultCaller) Format() string { return "json" }
|
||||
func (*imReadResultCaller) DryRun() bool { return false }
|
||||
func (c *imReadResultCaller) DryRun() bool { return c.dryRun }
|
||||
func (*imReadResultCaller) Fields() string { return "" }
|
||||
func (*imReadResultCaller) JQ() string { return "" }
|
||||
|
||||
@@ -63,6 +110,8 @@ func executeIMReadCommand(t *testing.T, caller *imReadResultCaller, processArgs
|
||||
os.Args = processArgs
|
||||
|
||||
root := build()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SetOut(&stdout)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
@@ -85,13 +134,14 @@ func requireSameJSON(t *testing.T, got, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
|
||||
payload := `{
|
||||
"result": {
|
||||
"messages": [
|
||||
{"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}},
|
||||
{"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}}
|
||||
]
|
||||
{"openMessageId":"msg-1","content":{"text":"回复正文"},"createTime":101,"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}},
|
||||
{"openMessageId":"msg-2","content":{"text":"图片回复"},"createTime":102,"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
|
||||
@@ -104,7 +154,356 @@ func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) {
|
||||
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
|
||||
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
|
||||
}
|
||||
requireSameJSON(t, got, payload)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &result); err != nil {
|
||||
t.Fatalf("decode command output: %v\noutput: %s", err, got)
|
||||
}
|
||||
if _, exists := result["contractVersion"]; exists {
|
||||
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
|
||||
}
|
||||
messages, ok := result["messages"].([]any)
|
||||
if !ok || len(messages) != 2 {
|
||||
t.Fatalf("messages = %#v", result["messages"])
|
||||
}
|
||||
first, ok := messages[0].(map[string]any)
|
||||
if !ok || first["messageId"] != "msg-1" || first["openMessageId"] != "msg-1" || first["text"] != "回复正文" {
|
||||
t.Fatalf("first projected message = %#v", messages[0])
|
||||
}
|
||||
if _, ok := first["content"].(map[string]any); !ok {
|
||||
t.Fatalf("legacy content not preserved: %#v", first)
|
||||
}
|
||||
quoted, ok := first["quotedMessage"].(map[string]any)
|
||||
if !ok || quoted["msgType"] != "merged_forward" {
|
||||
t.Fatalf("merged-forward quote = %#v", first["quotedMessage"])
|
||||
}
|
||||
quotedContent, ok := quoted["content"].(map[string]any)
|
||||
items, itemsOK := quotedContent["items"].([]any)
|
||||
if !ok || !itemsOK || len(items) != 1 {
|
||||
t.Fatalf("merged-forward quote content = %#v", quoted["content"])
|
||||
}
|
||||
second, ok := messages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("second projected message = %#v", messages[1])
|
||||
}
|
||||
quoted, ok = second["quotedMessage"].(map[string]any)
|
||||
if !ok || quoted["msgType"] != "image" {
|
||||
t.Fatalf("image quote = %#v", second["quotedMessage"])
|
||||
}
|
||||
quotedContent, ok = quoted["content"].(map[string]any)
|
||||
if !ok || quotedContent["mediaId"] != "media-1" {
|
||||
t.Fatalf("image quote content = %#v", quoted["content"])
|
||||
}
|
||||
legacy, ok := result["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("legacy result envelope missing: %#v", result["result"])
|
||||
}
|
||||
legacyMessages, ok := legacy["messages"].([]any)
|
||||
if !ok || len(legacyMessages) != 2 {
|
||||
t.Fatalf("legacy result.messages = %#v", legacy["messages"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
|
||||
payload := `{
|
||||
"result": {
|
||||
"conversationMessagesList": [{
|
||||
"openConversationId": "cid-1",
|
||||
"title": "项目群",
|
||||
"messages": [{"openMessageId":"msg-search-1","messageId":"legacy-conflict","content":{"richText":"发布计划"},"createTime":201}]
|
||||
}],
|
||||
"nextCursor": "cursor-2"
|
||||
}
|
||||
}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{"search_messages_by_keyword": payload}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "search", "--query", "发布计划",
|
||||
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("chat message search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "search_messages_by_keyword"}) {
|
||||
t.Fatalf("calls = %#v, want chat/search_messages_by_keyword", caller.calls)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &result); err != nil {
|
||||
t.Fatalf("decode command output: %v\noutput: %s", err, got)
|
||||
}
|
||||
if _, exists := result["contractVersion"]; exists {
|
||||
t.Fatalf("typed search added an out-of-scope contract envelope: %#v", result)
|
||||
}
|
||||
messages, ok := result["messages"].([]any)
|
||||
if !ok || len(messages) != 1 {
|
||||
t.Fatalf("messages = %#v", result["messages"])
|
||||
}
|
||||
message, ok := messages[0].(map[string]any)
|
||||
if !ok || message["messageId"] != "msg-search-1" || message["openMessageId"] != "msg-search-1" ||
|
||||
message["conversationId"] != "cid-1" || message["text"] != "发布计划" {
|
||||
t.Fatalf("projected search message = %#v", messages[0])
|
||||
}
|
||||
if _, ok := message["content"].(map[string]any); !ok {
|
||||
t.Fatalf("legacy content not preserved: %#v", message)
|
||||
}
|
||||
legacy, ok := result["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("legacy result envelope missing: %#v", result["result"])
|
||||
}
|
||||
legacyGroups, ok := legacy["conversationMessagesList"].([]any)
|
||||
if !ok || len(legacyGroups) != 1 {
|
||||
t.Fatalf("legacy result.conversationMessagesList = %#v", legacy["conversationMessagesList"])
|
||||
}
|
||||
if legacy["nextCursor"] != "cursor-2" {
|
||||
t.Fatalf("legacy result.nextCursor = %#v", legacy["nextCursor"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageScopedSearchProjectsStableFields(t *testing.T) {
|
||||
payload := `{
|
||||
"result": {
|
||||
"conversationMessagesList": [{
|
||||
"openConversationId": "cid-1",
|
||||
"messages": [{"openMessageId":"msg-scoped-1","content":{"text":"范围内消息"}}]
|
||||
}],
|
||||
"hasMore": false
|
||||
}
|
||||
}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{
|
||||
"get_conversation_info": `{}`,
|
||||
"search_messages_by_keyword": payload,
|
||||
}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "search", "--query", "范围内消息", "--group", "cid-1",
|
||||
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("scoped chat message search returned error: %v", err)
|
||||
}
|
||||
wantCalls := []imReadResultCall{
|
||||
{productID: "chat", toolName: "get_conversation_info"},
|
||||
{productID: "chat", toolName: "search_messages_by_keyword"},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls, wantCalls) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, wantCalls)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &result); err != nil {
|
||||
t.Fatalf("decode command output: %v\noutput: %s", err, got)
|
||||
}
|
||||
messages, ok := result["messages"].([]any)
|
||||
if !ok || len(messages) != 1 {
|
||||
t.Fatalf("messages = %#v", result["messages"])
|
||||
}
|
||||
message, _ := messages[0].(map[string]any)
|
||||
if message["messageId"] != "msg-scoped-1" || message["openMessageId"] != "msg-scoped-1" ||
|
||||
message["conversationId"] != "cid-1" || message["text"] != "范围内消息" {
|
||||
t.Fatalf("projected scoped message = %#v", message)
|
||||
}
|
||||
scope, _ := result["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["sourceComplete"] != true {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
legacy, _ := result["result"].(map[string]any)
|
||||
if _, ok := legacy["conversationMessagesList"].([]any); !ok {
|
||||
t.Fatalf("legacy result envelope missing: %#v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListPreservesNonJSONResponse(t *testing.T) {
|
||||
const payload = "upstream temporarily returned plain text"
|
||||
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
|
||||
if err != nil {
|
||||
t.Fatalf("chat message list returned error: %v", err)
|
||||
}
|
||||
if got != payload+"\n" {
|
||||
t.Fatalf("command output = %q, want raw payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadsRejectEmptyToolResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
caller *imReadResultCaller
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "list empty text",
|
||||
toolName: "list_conversation_message_v2",
|
||||
caller: &imReadResultCaller{responses: map[string]string{
|
||||
"list_conversation_message_v2": " \n ",
|
||||
}},
|
||||
args: []string{"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00"},
|
||||
},
|
||||
{
|
||||
name: "search without text content",
|
||||
toolName: "search_messages_by_keyword",
|
||||
caller: &imReadResultCaller{results: map[string]*edition.ToolResult{
|
||||
"search_messages_by_keyword": {Content: []edition.ContentBlock{{Type: "image"}}},
|
||||
}},
|
||||
args: []string{"message", "search", "--query", "发布计划",
|
||||
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := executeIMReadCommand(t, tc.caller, []string{"dws", "chat"}, newChatCommand, tc.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded with output %q", tc.toolName, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("%s output = %q, want no success payload", tc.toolName, got)
|
||||
}
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" ||
|
||||
apiErr.Operation != "chat/"+tc.toolName || !apiErr.RetryableSet || !apiErr.Retryable {
|
||||
t.Fatalf("%s error = %#v", tc.toolName, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListPreservesTopLevelMessageFields(t *testing.T) {
|
||||
payload := `{
|
||||
"messages": [{
|
||||
"openMessageId": "msg-top-1",
|
||||
"content": {"text": "顶层正文"},
|
||||
"msgType": "text",
|
||||
"sender": {"name": "张三", "department": "研发"},
|
||||
"senderName": "张三",
|
||||
"quotedMessage": {
|
||||
"msgType": "image",
|
||||
"content": {"mediaId": "media-1", "caption": "原图说明"},
|
||||
"extension": {"source": "legacy-quote"}
|
||||
},
|
||||
"forwarded": [{
|
||||
"openMessageId": "forward-1",
|
||||
"content": {"text": "转发正文"},
|
||||
"extension": {"source": "legacy-forward"}
|
||||
}],
|
||||
"reactions": [{
|
||||
"emoji": "like",
|
||||
"count": 2,
|
||||
"extension": {"source": "legacy-reaction"}
|
||||
}],
|
||||
"extensionField": {"source": "legacy"}
|
||||
}, {
|
||||
"messageId": "msg-top-2",
|
||||
"text": "稳定正文"
|
||||
}],
|
||||
"count": 99,
|
||||
"partial": true,
|
||||
"failedCount": 1,
|
||||
"failures": [{"stage":"legacy","error":"legacy failure"}],
|
||||
"hasMore": false
|
||||
}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
|
||||
if err != nil {
|
||||
t.Fatalf("chat message list returned error: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &result); err != nil {
|
||||
t.Fatalf("decode command output: %v\noutput: %s", err, got)
|
||||
}
|
||||
messages, ok := result["messages"].([]any)
|
||||
if !ok || len(messages) != 2 {
|
||||
t.Fatalf("messages = %#v", result["messages"])
|
||||
}
|
||||
message, ok := messages[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("message = %#v", messages[0])
|
||||
}
|
||||
if message["messageId"] != "msg-top-1" || message["text"] != "顶层正文" {
|
||||
t.Fatalf("stable fields = %#v", message)
|
||||
}
|
||||
if message["msgType"] != "text" || message["senderName"] != "张三" {
|
||||
t.Fatalf("legacy message fields = %#v", message)
|
||||
}
|
||||
if !reflect.DeepEqual(message["sender"], map[string]any{"name": "张三", "department": "研发"}) {
|
||||
t.Fatalf("legacy sender = %#v", message["sender"])
|
||||
}
|
||||
wantQuoted := map[string]any{
|
||||
"msgType": "image",
|
||||
"content": map[string]any{"mediaId": "media-1", "caption": "原图说明"},
|
||||
"extension": map[string]any{"source": "legacy-quote"},
|
||||
}
|
||||
if !reflect.DeepEqual(message["quotedMessage"], wantQuoted) {
|
||||
t.Fatalf("legacy quotedMessage = %#v", message["quotedMessage"])
|
||||
}
|
||||
wantForwarded := []any{map[string]any{
|
||||
"openMessageId": "forward-1",
|
||||
"content": map[string]any{"text": "转发正文"},
|
||||
"extension": map[string]any{"source": "legacy-forward"},
|
||||
}}
|
||||
if !reflect.DeepEqual(message["forwarded"], wantForwarded) {
|
||||
t.Fatalf("legacy forwarded = %#v", message["forwarded"])
|
||||
}
|
||||
wantReactions := []any{map[string]any{
|
||||
"emoji": "like",
|
||||
"count": float64(2),
|
||||
"extension": map[string]any{"source": "legacy-reaction"},
|
||||
}}
|
||||
if !reflect.DeepEqual(message["reactions"], wantReactions) {
|
||||
t.Fatalf("legacy reactions = %#v", message["reactions"])
|
||||
}
|
||||
if extension, ok := message["extensionField"].(map[string]any); !ok || extension["source"] != "legacy" {
|
||||
t.Fatalf("extensionField = %#v", message["extensionField"])
|
||||
}
|
||||
stableOnly, ok := messages[1].(map[string]any)
|
||||
if !ok || stableOnly["openMessageId"] != "msg-top-2" || stableOnly["content"] != "稳定正文" {
|
||||
t.Fatalf("legacy aliases from stable fields = %#v", messages[1])
|
||||
}
|
||||
if result["count"] != float64(99) || result["partial"] != true || result["failedCount"] != float64(1) {
|
||||
t.Fatalf("legacy top-level envelope fields = %#v", result)
|
||||
}
|
||||
if _, exists := result["contractVersion"]; exists {
|
||||
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
|
||||
}
|
||||
failures, ok := result["failures"].([]any)
|
||||
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "legacy" {
|
||||
t.Fatalf("legacy failures = %#v", result["failures"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListDryRunKeepsPreviewPath(t *testing.T) {
|
||||
caller := &imReadResultCaller{dryRun: true}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
|
||||
if err != nil {
|
||||
t.Fatalf("chat message list dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run calls = %#v, want none", caller.calls)
|
||||
}
|
||||
var preview map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &preview); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\noutput: %s", err, got)
|
||||
}
|
||||
if preview["dry_run"] != true || preview["executed"] != false || preview["tool"] != "list_conversation_message_v2" {
|
||||
t.Fatalf("dry-run preview = %#v", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListRawPreservesLargeIntegers(t *testing.T) {
|
||||
const payload = `{"messages":[{"openMessageId":"msg-1","content":{"text":"正文"},"sequence":9007199254740993}],"hasMore":false}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
|
||||
|
||||
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00", "--format", "raw")
|
||||
if err != nil {
|
||||
t.Fatalf("chat message list returned error: %v", err)
|
||||
}
|
||||
if !bytes.Contains([]byte(got), []byte(`"sequence":9007199254740993`)) {
|
||||
t.Fatalf("raw output changed large integer: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingMessageListPreservesContent(t *testing.T) {
|
||||
|
||||
+43
-24
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -133,10 +134,11 @@ type LeafContract = corecmd.ContractDecl
|
||||
//
|
||||
// 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。
|
||||
type LeafSpec struct {
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
OutputRollout output.RolloutState
|
||||
|
||||
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
|
||||
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。
|
||||
@@ -167,7 +169,8 @@ type LeafSpec struct {
|
||||
|
||||
// Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams
|
||||
// 装配完成;Call 不应再写业务参数。分页等横切由领域工具处理,不进声明。
|
||||
Call func(cmd *cobra.Command, tool string, args map[string]any) error
|
||||
Call func(cmd *cobra.Command, tool string, args map[string]any) error
|
||||
ResultCall func(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error)
|
||||
|
||||
// Validate 是编排钩子(条件式校验),不是声明面。单 flag 转换用
|
||||
// LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。
|
||||
@@ -225,6 +228,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
if spec.Call != nil {
|
||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Call must be nil (metadata-only mode)", name))
|
||||
}
|
||||
if spec.ResultCall != nil {
|
||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): ResultCall must be nil (metadata-only mode)", name))
|
||||
}
|
||||
if spec.RunE != nil {
|
||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): RunE must be nil (metadata-only mode)", name))
|
||||
}
|
||||
@@ -241,6 +247,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Contract is required", name))
|
||||
}
|
||||
corecmd.AttachContract(cmd, spec.Safety, spec.Contract, cmd.Short, cmd.Long)
|
||||
if spec.OutputRollout != "" {
|
||||
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||
}
|
||||
|
||||
confirm := strings.TrimSpace(spec.Safety.Confirmation) == "user_required"
|
||||
if spec.Validate == nil && !confirm {
|
||||
@@ -390,30 +399,40 @@ func HasContractConfirmDeferred(cmd *cobra.Command) bool {
|
||||
// dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product
|
||||
// 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。
|
||||
func FromLeafSpec(spec LeafSpec) corecmd.Spec {
|
||||
if spec.Call != nil && spec.ResultCall != nil {
|
||||
panic(fmt.Sprintf("command %q must not declare both Call and ResultCall", spec.Use))
|
||||
}
|
||||
cs := corecmd.Spec{
|
||||
Use: spec.Use,
|
||||
Short: spec.Short,
|
||||
Long: spec.Long,
|
||||
Example: spec.Example,
|
||||
Flags: spec.Flags,
|
||||
Constraints: spec.Constraints,
|
||||
Safety: spec.Safety,
|
||||
ConfirmFirst: spec.ConfirmFirst,
|
||||
ConstParams: spec.ConstParams,
|
||||
Contract: spec.Contract,
|
||||
Validate: spec.Validate,
|
||||
PostMount: spec.PostMount,
|
||||
RunE: spec.RunE,
|
||||
Use: spec.Use,
|
||||
Short: spec.Short,
|
||||
Long: spec.Long,
|
||||
Example: spec.Example,
|
||||
OutputRollout: spec.OutputRollout,
|
||||
Flags: spec.Flags,
|
||||
Constraints: spec.Constraints,
|
||||
Safety: spec.Safety,
|
||||
ConfirmFirst: spec.ConfirmFirst,
|
||||
ConstParams: spec.ConstParams,
|
||||
Contract: spec.Contract,
|
||||
Validate: spec.Validate,
|
||||
PostMount: spec.PostMount,
|
||||
RunE: spec.RunE,
|
||||
}
|
||||
if spec.RunE == nil {
|
||||
cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error {
|
||||
if spec.Call != nil {
|
||||
return spec.Call(c.Command(), spec.Tool, toolArgs)
|
||||
if spec.ResultCall != nil {
|
||||
cs.ResultInvoke = func(c *corecmd.Ctx, toolArgs map[string]any) (output.CommandResult, error) {
|
||||
return spec.ResultCall(c.Command(), spec.Tool, toolArgs)
|
||||
}
|
||||
if spec.Server != "" {
|
||||
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
|
||||
} else {
|
||||
cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error {
|
||||
if spec.Call != nil {
|
||||
return spec.Call(c.Command(), spec.Tool, toolArgs)
|
||||
}
|
||||
if spec.Server != "" {
|
||||
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
|
||||
}
|
||||
return callMCPTool(spec.Tool, toolArgs)
|
||||
}
|
||||
return callMCPTool(spec.Tool, toolArgs)
|
||||
}
|
||||
}
|
||||
return cs
|
||||
|
||||
@@ -38,7 +38,32 @@ type Formatter struct {
|
||||
}
|
||||
|
||||
func NewFormatter() *Formatter {
|
||||
return &Formatter{w: os.Stdout, errW: os.Stderr}
|
||||
return NewFormatterWithWriters(os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
// NewFormatterWithWriters 是注入式构造函数(B45,WS1 改动点3):数据流 w 与
|
||||
// 诊断流 errW 均由调用方注入。nil 按默认进程流处理(w→os.Stdout、
|
||||
// errW→os.Stderr),因此 NewFormatter 的既有行为不变,只是收口到本构造器。
|
||||
func NewFormatterWithWriters(w, errW io.Writer) *Formatter {
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
if errW == nil {
|
||||
errW = os.Stderr
|
||||
}
|
||||
return &Formatter{w: w, errW: errW}
|
||||
}
|
||||
|
||||
// SetWriters 运行期替换两个写入目标(B46,WS1 改动点3):deps.Out 的 writer
|
||||
// 不再是构造期一次性硬编码,而是可注入接缝。nil 侧保留当前值(支持单侧替换)。
|
||||
// 既有的 deps.Out.w / deps.Out.errW 直接替换习惯不受影响。
|
||||
func (f *Formatter) SetWriters(w, errW io.Writer) {
|
||||
if w != nil {
|
||||
f.w = w
|
||||
}
|
||||
if errW != nil {
|
||||
f.errW = errW
|
||||
}
|
||||
}
|
||||
|
||||
// PrintJSON serializes data as pretty-printed JSON and writes it to the output stream.
|
||||
@@ -109,6 +134,9 @@ func padRight(s string, width int) string {
|
||||
return s + strings.Repeat(" ", width-rw)
|
||||
}
|
||||
|
||||
// PrintTable preserves the legacy formatter contract. Unified-result commands
|
||||
// bypass this formatter and use internal/output, so rollout does not silently
|
||||
// move bytes for commands that have not migrated yet.
|
||||
func (f *Formatter) PrintTable(headers []string, rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
|
||||
@@ -43,6 +43,7 @@ type PagedMCPCommandConfig struct {
|
||||
AggregationMode PagedAggregationMode
|
||||
BuildArgs func(*cobra.Command) (map[string]any, error)
|
||||
Fallback func(map[string]any) error
|
||||
ProjectResult func(map[string]any) map[string]any
|
||||
}
|
||||
|
||||
type pagedCommandOptions struct {
|
||||
@@ -314,6 +315,9 @@ func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig,
|
||||
paging["resumeCursorReliable"] = false
|
||||
}
|
||||
envelope["paging"] = paging
|
||||
if cfg.ProjectResult != nil {
|
||||
envelope = cfg.ProjectResult(envelope)
|
||||
}
|
||||
return deps.Out.PrintJSON(envelope)
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,77 @@ func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatDataAuthCrossOrgRequiresConfirmation(t *testing.T) {
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"data-auth", "cross-org", "--target-org-id", "439446171")
|
||||
requireWukongWeeklySyncConfirmation(t, err)
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
caller = &wukongWeeklySyncCaller{}
|
||||
_, _, err = executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"data-auth", "cross-org", "--target-org-id", "439446171", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed cross-org data auth returned error: %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncCall(t, caller, wukongWeeklySyncCall{
|
||||
server: "im",
|
||||
tool: "chat_permission_grant",
|
||||
args: map[string]any{
|
||||
"agentCode": "wukong",
|
||||
"grantCategory": "data",
|
||||
"grantParams": `{"targetOrgId":"439446171"}`,
|
||||
"grantType": "timed",
|
||||
"scope": "chat.data:cross-org",
|
||||
"ttl": "24h",
|
||||
},
|
||||
})
|
||||
if _, ok := caller.calls[0].args["yes"]; ok {
|
||||
t.Fatalf("confirmed payload leaked yes flag: %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupShareInviteRequiresConfirmation(t *testing.T) {
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"group", "share-invite", "--target", "cid-target")
|
||||
if err == nil || !strings.Contains(err.Error(), "source") {
|
||||
t.Fatalf("missing source error = %v, want source", err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
caller = &wukongWeeklySyncCaller{}
|
||||
_, _, err = executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"group", "share-invite", "--source", "cid-source", "--target", "cid-target")
|
||||
requireWukongWeeklySyncConfirmation(t, err)
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
caller = &wukongWeeklySyncCaller{}
|
||||
_, _, err = executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"group", "share-invite",
|
||||
"--source", "cid-source",
|
||||
"--target", "cid-target",
|
||||
"--expires-seconds", "86400",
|
||||
"--uuid", "invite-uuid",
|
||||
"--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed share-invite returned error: %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncCall(t, caller, wukongWeeklySyncCall{
|
||||
server: "im",
|
||||
tool: "share_group_invite_url",
|
||||
args: map[string]any{
|
||||
"expiresSeconds": int64(86400),
|
||||
"sourceOpenConversationId": "cid-source",
|
||||
"targetOpenConversationId": "cid-target",
|
||||
"uuid": "invite-uuid",
|
||||
},
|
||||
})
|
||||
if _, ok := caller.calls[0].args["yes"]; ok {
|
||||
t.Fatalf("confirmed payload leaked yes flag: %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWukongWeeklyChatMessageEditValidation(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
edit, _, findErr := root.Find([]string{"message", "edit"})
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// WriteEnvelope 是统一信封的出口函数(B0 dev 试点桥接,WS1 改动点2/4)。
|
||||
// 它复用 envelope.go 的权威 Envelope 类型,把信封按当前 --format 分发写出。
|
||||
// Phase B 的 Emitter 类型将在此之上泛化(渲染先进 buffer、exit code 映射等);
|
||||
// 本函数仅提供 Phase F dev 域迁移所需的「信封 → cmd 流」出口,不重复定义类型。
|
||||
//
|
||||
// format 分发(契约规范 §5.2,经 renderEnvelope 实现):
|
||||
// - json(默认):输出完整信封——唯一 JSON 契约(§5.2 首行)。
|
||||
// - table/pretty:业务数据人读视图 + meta 摘要(不输出信封外壳)。
|
||||
// - csv/ndjson:裸记录(无信封);meta.pagination 走 stderr 诊断行。
|
||||
// - raw:数据载荷透传(与 Write 的 raw 语义一致)。
|
||||
//
|
||||
// 数据出口一律写 cmd.OutOrStdout()、诊断出口写 cmd.ErrOrStderr()(均可被
|
||||
// 测试重定向),不硬编码 os.Stdout/os.Stderr。未知 --format 值按 §5.2/AC-09
|
||||
// 降级 fallback 并在此向 stderr 写一条 warning(不崩不静默)。
|
||||
func WriteEnvelope(cmd *cobra.Command, env *Envelope, fallback Format) error {
|
||||
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||
format := fallback
|
||||
fields, jq, warning := "", "", ""
|
||||
if cmd != nil {
|
||||
w = cmd.OutOrStdout()
|
||||
errW = cmd.ErrOrStderr()
|
||||
format, warning = resolveFormatWithWarning(cmd, fallback)
|
||||
fields = ResolveFields(cmd)
|
||||
jq = ResolveJQ(cmd)
|
||||
}
|
||||
if env == nil {
|
||||
env = nilFallbackEnvelope()
|
||||
}
|
||||
if err := renderEnvelope(w, errW, env, format, fields, jq); err != nil {
|
||||
return err
|
||||
}
|
||||
// AC-09「不静默」:warning 仅在信封成功写出后补写 stderr——
|
||||
// 写出失败时错误优先,不留半截通道。
|
||||
if warning != "" {
|
||||
if _, werr := fmt.Fprintln(errW, "[WARN] "+warning); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteEnvelopeTo 是 WriteEnvelope 的 writer 级实现(签名自 B205 保持不变),
|
||||
// 供不持有 *cobra.Command 的调用方与单测直接使用。B37 起收编到 renderEnvelope
|
||||
// 统一分发:json/空 format 输出完整信封(唯一 JSON 契约);table/pretty 输出
|
||||
// 业务数据人读视图 + meta 摘要;csv/ndjson 输出裸记录(分页诊断行在本签名下
|
||||
// 无 stderr 出口可走,按 io.Discard 处理——需要诊断行的调用方走 WriteEnvelope
|
||||
// 的 cmd 路径或 Emitter)。nil 信封降级为 I3 合法的 failure 兜底信封(B208):
|
||||
// 携带 internal 类 ErrorInfo——框架最后的兜底不得产出被自身 Validate() 拒绝的形态。
|
||||
func WriteEnvelopeTo(w io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||
if env == nil {
|
||||
env = nilFallbackEnvelope()
|
||||
}
|
||||
return renderEnvelope(w, io.Discard, env, format, fields, jq)
|
||||
}
|
||||
|
||||
// EmitResult is the framework 2.0 CLI adapter. Unlike the legacy Emitter,
|
||||
// machine-readable JSON/NDJSON always writes the primary result to stdout for
|
||||
// every outcome; stderr is diagnostics-only. Human failure formats retain the
|
||||
// conventional stderr path. The result is validated before any byte is
|
||||
// rendered, and the returned exit code comes from the same immutable result.
|
||||
func EmitResult(cmd *cobra.Command, result CommandResult) (int, error) {
|
||||
code, _, err := emitResult(cmd, result)
|
||||
return code, err
|
||||
}
|
||||
|
||||
func emitResult(cmd *cobra.Command, result CommandResult) (int, bool, error) {
|
||||
if err := ValidateResult(result); err != nil {
|
||||
return exitCodeInternal, false, err
|
||||
}
|
||||
env := result.envelope()
|
||||
env = redactEnvelope(env)
|
||||
format, warning := resolveFormatWithWarning(cmd, FormatJSON)
|
||||
fields, jq := ResolveFields(cmd), ResolveJQ(cmd)
|
||||
stdout, stderr := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||
if cmd != nil {
|
||||
stdout, stderr = cmd.OutOrStdout(), cmd.ErrOrStderr()
|
||||
}
|
||||
|
||||
if env.Outcome == OutcomeFailure && format != FormatJSON && format != FormatNDJSON {
|
||||
message := "command failed"
|
||||
if env.Error != nil && strings.TrimSpace(env.Error.Message) != "" {
|
||||
message = env.Error.Message
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_, _ = fmt.Fprintln(&buf, "Error: "+message)
|
||||
n, err := writeAllCount(stderr, buf.Bytes())
|
||||
if err != nil {
|
||||
return exitCodeInternal, n > 0, err
|
||||
}
|
||||
return result.ExitCode(), n > 0, nil
|
||||
}
|
||||
|
||||
// Failure bypasses jq/fields so a filter cannot erase the typed error.
|
||||
if env.Outcome == OutcomeFailure {
|
||||
fields, jq = "", ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := renderEnvelope(&buf, stderr, env, format, fields, jq); err != nil {
|
||||
return exitCodeInternal, false, err
|
||||
}
|
||||
n, err := writeAllCount(stdout, buf.Bytes())
|
||||
if err != nil {
|
||||
return exitCodeInternal, n > 0, err
|
||||
}
|
||||
if warning != "" {
|
||||
// The primary result is already complete. Diagnostics are best-effort
|
||||
// and must not reverse its outcome when stderr is unavailable.
|
||||
_, _ = fmt.Fprintln(stderr, "[WARN] "+warning)
|
||||
}
|
||||
return result.ExitCode(), n > 0, nil
|
||||
}
|
||||
|
||||
// nilFallbackEnvelope 构造 nil 信封的兜底信封(B208,轮6裁决⑨)。保留
|
||||
// 「不 panic、必产合法 failure」的既有语义,且自身满足不变量 I3
|
||||
// (error 非空 ⇔ outcome==failure)、通过 Envelope.Validate()——框架出口
|
||||
// 兜底不得被自己的校验器判非法。Type 取 "internal":这是框架内部缺陷而非
|
||||
// 业务错误(§4 internal=5),Agent 可据 wire-stable 字段分支处置。
|
||||
func nilFallbackEnvelope() *Envelope {
|
||||
return &Envelope{
|
||||
OK: false,
|
||||
Outcome: OutcomeFailure,
|
||||
Error: &ErrorInfo{
|
||||
Type: "internal",
|
||||
Message: "nil envelope: framework fallback",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format 分发(B35~B40):信封 → format 矩阵的统一分发点
|
||||
// (契约规范 §5.2;适用域仅 stdout 数据通道——轮8裁决⑪)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderEnvelope 是信封渲染的统一分发点(B37,WS1 改动点4;契约规范 §5.2
|
||||
// format 矩阵)。渲染先进 buffer 再一次性写出:数据通道任何一步失败都不向
|
||||
// w 泄漏半截输出(§5.2 末条;与 B25 buffer-first 同口径)。
|
||||
//
|
||||
// 矩阵各行(适用域仅 stdout 数据通道——失败信封在 Emitter 流路由层恒以
|
||||
// 完整 JSON 写 stderr,从不进入本矩阵,轮8裁决⑪):
|
||||
//
|
||||
// - json / 空:完整信封——唯一 JSON 契约(§5.2 首行,B38);
|
||||
// - table / pretty:业务数据人读视图 + meta 层人读摘要(count/pagination),
|
||||
// 不输出信封外壳(§5.2 矩阵行,B39);
|
||||
// - csv / ndjson:裸记录(无信封外壳);meta.pagination 走 errW 的 stderr
|
||||
// 诊断行,不污染裸记录机器可读流(§5.2 矩阵行,B40);
|
||||
// - raw:数据载荷透传(writeRaw 语义)。
|
||||
//
|
||||
// --jq 优先于 format:对**完整信封**求值后直接输出结果(§5.2 矩阵行,B42)。
|
||||
// --fields 先投影数据载荷再按 format 分发(成功通道联动)。errW 为 nil 时按
|
||||
// io.Discard 处理(csv/ndjson 诊断行无出口即丢弃)。
|
||||
//
|
||||
// buffer-first 纪律:全部分发路径(含 json/jq)先渲染进内存 buffer,成功后
|
||||
// 才一次性写出到 w(§5.2 末条对分发点整体成立)——渲染失败零泄漏、写出
|
||||
// 失败原子中止、短写报 io.ErrShortWrite。csv/ndjson 的 stderr 诊断行不经
|
||||
// buffer,且在数据渲染成功后才写(渲染失败不留诊断噪声)。
|
||||
func renderEnvelope(w io.Writer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||
if errW == nil {
|
||||
errW = io.Discard
|
||||
}
|
||||
if env == nil {
|
||||
env = nilFallbackEnvelope()
|
||||
}
|
||||
env = redactEnvelope(env)
|
||||
var buf bytes.Buffer
|
||||
if err := renderEnvelopeInto(&buf, errW, env, format, fields, jq); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(w, buf.Bytes())
|
||||
}
|
||||
|
||||
// renderEnvelopeInto 按 format 矩阵把信封渲染进 buf(renderEnvelope 的渲染段)。
|
||||
func renderEnvelopeInto(buf *bytes.Buffer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||
// 失败信封不参与 format/fields/jq 分发(轮8裁决⑪;契约规范 §5.1 v1.1
|
||||
// 回写条款):恒输出完整 JSON 信封。§5.2 矩阵适用域仅 stdout 数据通道,
|
||||
// 失败信封无 data 载荷(I3),若按矩阵分发或经 jq/fields 过滤,§2.4
|
||||
// wire-stable 字段组将从错误通道消失。
|
||||
if env.Outcome == OutcomeFailure {
|
||||
if format == FormatNDJSON {
|
||||
return Write(buf, FormatNDJSON, env)
|
||||
}
|
||||
return WriteFiltered(buf, FormatJSON, env, "", "")
|
||||
}
|
||||
if strings.TrimSpace(jq) != "" {
|
||||
// jq 优先于 format:对完整信封求值后直接输出(§5.2 矩阵行)。
|
||||
return ApplyJQ(buf, env, strings.TrimSpace(jq))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatJSON, "":
|
||||
// json(默认)始终保留完整信封。--fields 只投影业务载荷,
|
||||
// ok/outcome/meta 等稳定契约字段仍必须可供 Agent 分支和续页。
|
||||
if trimmed := strings.TrimSpace(fields); trimmed != "" {
|
||||
projected := *env
|
||||
projected.Data = SelectFields(env.Data, strings.Split(trimmed, ","))
|
||||
return WriteFiltered(buf, FormatJSON, &projected, "", "")
|
||||
}
|
||||
return WriteFiltered(buf, FormatJSON, env, "", "")
|
||||
default:
|
||||
}
|
||||
|
||||
payload := env.Data
|
||||
if trimmed := strings.TrimSpace(fields); trimmed != "" {
|
||||
payload = SelectFields(payload, strings.Split(trimmed, ","))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatTable, FormatPretty:
|
||||
if err := Write(buf, format, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary := renderMetaSummary(env.Meta); summary != "" {
|
||||
_, _ = fmt.Fprintf(buf, "\n%s\n", summary)
|
||||
}
|
||||
return nil
|
||||
case FormatCSV, FormatNDJSON:
|
||||
if err := Write(buf, format, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
// 分页元数据走 stderr 诊断行(§5.2 csv/ndjson 行)。partial_failure
|
||||
// 例外:§2.3 明确「stderr 零输出」,特例优先于 §5.2 诊断行通则。
|
||||
if env.Outcome != OutcomePartialFailure {
|
||||
return writePaginationDiagnostic(errW, env.Meta)
|
||||
}
|
||||
return nil
|
||||
case FormatRaw:
|
||||
return Write(buf, FormatRaw, payload)
|
||||
default:
|
||||
// 未知 Format 常量(归一化/ParseFormat 后理论不可达):按 json
|
||||
// 数据通道语义兜底,绝不静默丢弃载荷。--fields 同样投影业务载荷。
|
||||
if trimmed := strings.TrimSpace(fields); trimmed != "" {
|
||||
return WriteFiltered(buf, FormatJSON, env.Data, trimmed, "")
|
||||
}
|
||||
return WriteFiltered(buf, FormatJSON, env, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// writeAll 把 p 一次性写入 w;短写(接受了部分字节却未写全)报
|
||||
// io.ErrShortWrite——与 Emitter.writeOnce 同一短写纪律,绝不把截断输出当成功。
|
||||
func writeAll(w io.Writer, p []byte) error {
|
||||
_, err := writeAllCount(w, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeAllCount(w io.Writer, p []byte) (int, error) {
|
||||
n, err := w.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n < len(p) {
|
||||
return n, io.ErrShortWrite
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// renderMetaSummary 把信封 meta 层渲染为人读摘要行(§5.2:table/pretty 输出
|
||||
// 「业务数据 + 人读分页摘要」)。无 meta 内容返回空串。count 单独成行;
|
||||
// pagination 摘要复用 renderPaginationSummary。
|
||||
func renderMetaSummary(meta *Meta) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
if meta.Count != nil {
|
||||
lines = append(lines, fmt.Sprintf("count: %d", *meta.Count))
|
||||
}
|
||||
if meta.Pagination != nil {
|
||||
lines = append(lines, "pagination: "+renderPaginationSummary(meta.Pagination))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPaginationSummary 按 wire 字段名输出分页摘要:
|
||||
// endpoint_exhausted/pages/items/next_token(snake_case 与 wire 一致,Agent
|
||||
// 可交叉核对;§3 两态显式语义保留——false 也输出,因为它是「可续跑」的
|
||||
// 有意义状态)。
|
||||
func renderPaginationSummary(p *Pagination) string {
|
||||
parts := []string{fmt.Sprintf("endpoint_exhausted: %t", p.EndpointExhausted)}
|
||||
if p.Pages > 0 {
|
||||
parts = append(parts, fmt.Sprintf("pages: %d", p.Pages))
|
||||
}
|
||||
if p.Items > 0 {
|
||||
parts = append(parts, fmt.Sprintf("items: %d", p.Items))
|
||||
}
|
||||
if p.NextToken != "" {
|
||||
parts = append(parts, fmt.Sprintf("next_token: %s", p.NextToken))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writePaginationDiagnostic 把分页诊断行写到 errW(§5.2:csv/ndjson 裸记录
|
||||
// 格式下分页元数据走 stderr 诊断行)。无 meta/pagination 时零输出——
|
||||
// 不给无分页场景制造噪声。
|
||||
func writePaginationDiagnostic(errW io.Writer, meta *Meta) error {
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintln(errW, "[pagination] "+renderPaginationSummary(meta.Pagination))
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase B 泛化(B24~B29):Emitter 类型——success/pending/partial 走 stdout、
|
||||
// failure 走 stderr 的双向出口;渲染先进 buffer 再一次性写出。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Emitter 是统一信封的双向出口(B24,WS1 改动点2)。它持有 stdout/stderr 两个
|
||||
// 写入目标与当前 format/fields/jq 配置,按契约规范 §5.1 流向纪律把信封分发到
|
||||
// 正确的流:
|
||||
//
|
||||
// - success / pending / partial_failure → stdout(数据通道);
|
||||
// - failure(含 nil 信封降级)→ stderr,stdout 严格零字节(AC-11)。
|
||||
//
|
||||
// 每次写出都经过「先渲染进内存 buffer、再一次性写出」(B25,契约规范 §5.2
|
||||
// 末条),写一半失败不产生残缺 JSON。渲染复用 WriteEnvelopeTo(B205),
|
||||
// Emitter 不重新定义信封类型或 wire 键。
|
||||
type Emitter struct {
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
format Format
|
||||
fields string
|
||||
jq string
|
||||
}
|
||||
|
||||
// NewEmitter 构造 Emitter(B24)。nil writer 按 io.Discard 处理(等价丢弃,
|
||||
// 防 panic)。format 是解析后的当前 format(空值由 WriteEnvelopeTo 按 json
|
||||
// 处理);fields/jq 为命令解析出的 --fields/--jq 过滤条件。
|
||||
func NewEmitter(stdout, stderr io.Writer, format Format, fields, jq string) *Emitter {
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
if stderr == nil {
|
||||
stderr = io.Discard
|
||||
}
|
||||
return &Emitter{stdout: stdout, stderr: stderr, format: format, fields: fields, jq: jq}
|
||||
}
|
||||
|
||||
// Emit 按 env 的 outcome 把信封写到对应流(B26/B27/B28,契约规范 §5.1):
|
||||
//
|
||||
// - success / pending:完整信封落 stdout(B26,ok:true,§2.1/§2.2);
|
||||
// - partial_failure:三通道明细落 stdout、stderr 零输出(B28,§2.3);
|
||||
// - failure:信封落 stderr、stdout 严格零字节(B27,AC-11);
|
||||
// - nil 信封由 WriteEnvelopeTo 降级为 failure,同样落 stderr。
|
||||
//
|
||||
// exit 0 语义标注(B32 收口,契约规范 §4/I2):Emit 的流路由与
|
||||
// ExitCodeForEnvelope 的退出码语义一一对应——落 stdout 的信封
|
||||
// (success/pending/partial_failure 中 ok:true 的前两者)对应 exit 0,
|
||||
// 落 stderr 的 failure(含 nil 降级)对应非零退出码。Emitter 只负责写出,
|
||||
// 不自行 os.Exit;进程退出码由 runner 出口按同一映射统一决定(§4 规则)。
|
||||
//
|
||||
// 失败信封恒以完整 JSON 信封渲染并绕过 format/fields/jq:失败信封没有数据
|
||||
// 载荷(I3),§5.1 要求 stderr 上的失败信封恒为结构化信封;「非 json 只渲染
|
||||
// data」的分发规则只适用于携带数据的信封。若 --jq 作用于失败信封会把错误
|
||||
// 通道过滤为空,Agent 将丢失错误明细,故同样绕过。
|
||||
func (em *Emitter) Emit(env *Envelope) error {
|
||||
if env == nil || env.Outcome == OutcomeFailure {
|
||||
return em.writeOnce(em.stderr, env, FormatJSON, "", "")
|
||||
}
|
||||
return em.writeOnce(em.stdout, env, em.format, em.fields, em.jq)
|
||||
}
|
||||
|
||||
// writeOnce 先把信封完整渲染进内存 buffer,再一次性写出到 target(B25,
|
||||
// 契约规范 §5.2 末条「渲染先进 buffer 再一次性写出」):渲染失败不向 target
|
||||
// 泄漏任何字节;写出失败原子中止——writer 报错即原样返回,短写(接受了部分
|
||||
// 字节却未写全)报 io.ErrShortWrite,绝不把截断输出当成功。渲染统一走
|
||||
// renderEnvelope 分发点(B37),csv/ndjson 的分页诊断行(B40)以 em.stderr
|
||||
// 为诊断出口。
|
||||
func (em *Emitter) writeOnce(target io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||
if env == nil {
|
||||
env = nilFallbackEnvelope()
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := renderEnvelope(&buf, em.stderr, env, format, fields, jq); err != nil {
|
||||
return err
|
||||
}
|
||||
p := buf.Bytes()
|
||||
n, err := target.Write(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < len(p) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseFormat 把字符串形式的 format 值(来自 --format flag 或 caller 接口)
|
||||
// 归一化为 Format 常量;大小写不敏感,未知/空值返回 fallback。它是不持有
|
||||
// *cobra.Command 的调用方(如主漏斗 callMCPToolInternalOpts)的 ResolveFormat
|
||||
// 等价物,二者共用同一归一化规则。
|
||||
func ParseFormat(raw string, fallback Format) Format {
|
||||
return normalizeFormat(raw, fallback)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 退出码映射(B32/B33/B34):outcome / error 类别 → exit code
|
||||
// (契约规范 §4;不变量 I2 —— exit code == 0 ⇔ ok == true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 退出码表取值对齐:契约规范 §4 × 规划 v1.2 决策(OQ-1 已关闭——保留现行
|
||||
// 码表 api=1 / auth=2 / validation=3 / PAT=4 / internal=5 / discovery=6,
|
||||
// 仅新增 partial_failure 专用码 7;草案表中 validation→2 / confirmation→3
|
||||
// 的拆分属 wire 破坏性重排,放弃)。confirmation_required 是 failure 的子类,
|
||||
// 与 validation 共享 rc=3、以 subtype 区分(AC-13;runtime 同源于
|
||||
// internal/errors 的 ExitCode 表)。
|
||||
const (
|
||||
exitCodeOK = 0
|
||||
exitCodeAPI = 1
|
||||
exitCodeAuth = 2
|
||||
exitCodeValidation = 3 // confirmation_required 子类共享此码(OQ-1 定案)
|
||||
exitCodePermission = 4 // PAT / permission failures
|
||||
exitCodeInternal = 5
|
||||
exitCodeDiscovery = 6
|
||||
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
|
||||
)
|
||||
|
||||
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
|
||||
// 与 runtime apperrors.WithReason("confirmation_required") 形态同源。
|
||||
const subtypeConfirmationRequired = "confirmation_required"
|
||||
|
||||
// ExitCodeForEnvelope 计算信封对应的进程退出码(B33,AC-10/I2;契约规范 §4)。
|
||||
// 纯函数:只读信封字段、不做任何 I/O。映射规则:
|
||||
//
|
||||
// - success / pending → 0(I2:exit 0 ⇔ ok;异步受理不是失败,§1/§4);
|
||||
// - partial_failure → 7(部分成功专用码,区别于普通失败,§4);
|
||||
// - failure → 按 Error.Type 类别映射,confirmation_required 子类恒 3;
|
||||
// - nil 信封 → 5(与 WriteEnvelopeTo 的 internal 类兜底信封同源);
|
||||
// - 非法 outcome → 5(非法信封属框架缺陷,归 internal)。
|
||||
//
|
||||
// 判定只看 outcome / error 类别,不看可能被命令层篡改的 OK 字段(与 IsOK
|
||||
// 同口径,I1)。退出码由 runner 出口统一决定、命令层禁止自行 os.Exit
|
||||
// (§4 规则);本函数为 Emitter 语义标注、runner 映射与测试提供同一依据。
|
||||
func ExitCodeForEnvelope(env *Envelope) int {
|
||||
if env == nil {
|
||||
return exitCodeInternal
|
||||
}
|
||||
switch env.Outcome {
|
||||
case OutcomeSuccess, OutcomePending:
|
||||
return exitCodeOK
|
||||
case OutcomePartialFailure:
|
||||
return exitCodePartial
|
||||
case OutcomeFailure:
|
||||
return exitCodeForErrorInfo(env.Error)
|
||||
default:
|
||||
return exitCodeInternal
|
||||
}
|
||||
}
|
||||
|
||||
// exitCodeForErrorInfo 把失败信封的 error 类别映射为退出码(契约规范 §4)。
|
||||
// subtype=confirmation_required 优先于 type:门禁拦截无论挂在哪个 type 下
|
||||
// 都恒为 3(AC-13)。error 缺席(违反 I3 的形态)按 internal 处理——
|
||||
// 没有明细可分支的失败不得假装可恢复。未知 type 归 internal(不应发生)。
|
||||
func exitCodeForErrorInfo(info *ErrorInfo) int {
|
||||
if info == nil {
|
||||
return exitCodeInternal
|
||||
}
|
||||
if info.Subtype == subtypeConfirmationRequired {
|
||||
return exitCodeValidation
|
||||
}
|
||||
switch info.Type {
|
||||
case "api":
|
||||
return exitCodeAPI
|
||||
case "auth":
|
||||
return exitCodeAuth
|
||||
case "validation":
|
||||
return exitCodeValidation
|
||||
case "permission":
|
||||
return exitCodePermission
|
||||
case "discovery":
|
||||
return exitCodeDiscovery
|
||||
default:
|
||||
return exitCodeInternal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
var errEmitterInjected = errors.New("emitter injected write failure")
|
||||
|
||||
// emitterFailingWriter 记录被接受的字节并在写出时失败,
|
||||
// 用于 B25「写一半失败不产生残缺 JSON」断言。
|
||||
type emitterFailingWriter struct {
|
||||
acceptN int // >0 时先接受 acceptN 字节再报错(模拟写到一半失败)
|
||||
accepted []byte
|
||||
}
|
||||
|
||||
func (w *emitterFailingWriter) Write(p []byte) (int, error) {
|
||||
if w.acceptN > 0 && len(w.accepted) < w.acceptN {
|
||||
k := w.acceptN - len(w.accepted)
|
||||
if k > len(p) {
|
||||
k = len(p)
|
||||
}
|
||||
w.accepted = append(w.accepted, p[:k]...)
|
||||
return k, errEmitterInjected
|
||||
}
|
||||
return 0, errEmitterInjected
|
||||
}
|
||||
|
||||
// emitterCountingWriter 统计 Write 调用次数并留存全部字节。
|
||||
type emitterCountingWriter struct {
|
||||
writes int
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *emitterCountingWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
// emitterShortWriter 接受一半字节但谎报成功(n < len(p), err == nil),
|
||||
// Emitter 必须检出短写而不是把截断输出当成功。
|
||||
type emitterShortWriter struct {
|
||||
n int
|
||||
}
|
||||
|
||||
func (w *emitterShortWriter) Write(p []byte) (int, error) {
|
||||
w.n = len(p) / 2
|
||||
return w.n, nil
|
||||
}
|
||||
|
||||
func newPartialEnvelopeFixture() *Envelope {
|
||||
return NewPartialEnvelope(map[string]any{
|
||||
"total": 3,
|
||||
"succeeded": []any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||
"failed": []any{map[string]any{"id": "b", "error": map[string]any{"type": "api", "code": 40001}}},
|
||||
"unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}},
|
||||
})
|
||||
}
|
||||
|
||||
// --- B24:Emitter 类型与构造 ---
|
||||
|
||||
func TestNewEmitterHandlesNilWriters(t *testing.T) {
|
||||
em := NewEmitter(nil, nil, FormatJSON, "", "")
|
||||
if em == nil {
|
||||
t.Fatal("NewEmitter returned nil")
|
||||
}
|
||||
// nil writer 等价 io.Discard:不 panic,写出成功且无副作用。
|
||||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatalf("Emit with nil writers: %v", err)
|
||||
}
|
||||
if err := em.Emit(NewFailureEnvelope(&ErrorInfo{Type: "api"})); err != nil {
|
||||
t.Fatalf("Emit failure with nil writers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmitterKeepsConfig(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatTable, "id,name", ".data")
|
||||
if em.format != FormatTable || em.fields != "id,name" || em.jq != ".data" {
|
||||
t.Fatalf("NewEmitter dropped config: format=%q fields=%q jq=%q", em.format, em.fields, em.jq)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B26:成功信封 stdout 出口(恰为一个完整 JSON 文档)---
|
||||
|
||||
func TestEmitterSuccessEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"})
|
||||
env.Meta = &Meta{Count: NewCount(1)}
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit success: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("success envelope must not touch stderr, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not exactly one complete JSON document: %v\n%s", err, stdout.String())
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
|
||||
if err := dec.Decode(&map[string]any{}); err != nil {
|
||||
t.Fatalf("first JSON document invalid: %v", err)
|
||||
}
|
||||
if dec.More() {
|
||||
t.Fatalf("stdout must contain exactly one JSON document, got trailing content: %s", stdout.String())
|
||||
}
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||
}
|
||||
if decoded["outcome"] != "success" {
|
||||
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPendingEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"})
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit pending: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("pending envelope must not touch stderr, got %d bytes", stderr.Len())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||
t.Fatalf("pending envelope ok = %v, want true", decoded["ok"])
|
||||
}
|
||||
if decoded["outcome"] != "pending" {
|
||||
t.Fatalf("outcome = %v, want pending", decoded["outcome"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- B27:失败信封 stderr 出口(stdout 严格零字节)---
|
||||
|
||||
func TestEmitterFailureEnvelopeGoesToStderrWithZeroStdout(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018})
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit failure: %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure envelope must leave stdout empty, got %d bytes: %s", stdout.Len(), stdout.String())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stderr failure envelope is not valid JSON: %v\n%s", err, stderr.String())
|
||||
}
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||
t.Fatalf("failure envelope ok = %v, want false (bool)", decoded["ok"])
|
||||
}
|
||||
if decoded["outcome"] != "failure" {
|
||||
t.Fatalf("outcome = %v, want failure", decoded["outcome"])
|
||||
}
|
||||
if _, hasError := decoded["error"]; !hasError {
|
||||
t.Fatalf("failure envelope must carry error detail: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterNilEnvelopeDegradesToFailureOnStderr(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(nil); err != nil {
|
||||
t.Fatalf("Emit(nil): %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("nil envelope must leave stdout empty, got %d bytes", stdout.Len())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" {
|
||||
t.Fatalf("nil envelope must degrade to failure: %s", stderr.String())
|
||||
}
|
||||
// B208(轮8顺带核查①:兜底修正后同步强化):降级信封必须是 I3
|
||||
// 合法形态——ok:false 恒真、error 明细存在且为 internal 类,
|
||||
// 兜底信封自身通过 Validate()。
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||
t.Fatalf("nil fallback ok = %v, want false (bool): %s", decoded["ok"], stderr.String())
|
||||
}
|
||||
errObj, hasErr := decoded["error"].(map[string]any)
|
||||
if !hasErr {
|
||||
t.Fatalf("nil fallback must carry error detail (I3, B208): %s", stderr.String())
|
||||
}
|
||||
if errObj["type"] != "internal" {
|
||||
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], stderr.String())
|
||||
}
|
||||
if err := nilFallbackEnvelope().Validate(); err != nil {
|
||||
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterFailureEnvelopeBypassesFormatAndJQ(t *testing.T) {
|
||||
// 失败信封恒以完整 JSON 信封落 stderr,不受 -f table/--jq 影响:
|
||||
// 失败信封无 data 载荷(I3),按 format 分发只会渲染空值,
|
||||
// Agent 必须总能读到结构化错误明细(§5.1/§2.4)。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatTable, "id", ".data")
|
||||
env := NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad flag"})
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit failure with table/jq config: %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout must stay empty for failure, got %d bytes", stdout.Len())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stderr failure envelope must stay full JSON regardless of format: %v\n%s", err, stderr.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" || decoded["error"] == nil {
|
||||
t.Fatalf("stderr lost failure envelope shape under -f table: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- B28:partial_failure 明细落 stdout、stderr 零输出 ---
|
||||
|
||||
func TestEmitterPartialFailureStdoutOnly(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
|
||||
if err := em.Emit(newPartialEnvelopeFixture()); err != nil {
|
||||
t.Fatalf("Emit partial_failure: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||
t.Fatalf("partial_failure ok = %v, want false (bool)", decoded["ok"])
|
||||
}
|
||||
if decoded["outcome"] != "partial_failure" {
|
||||
t.Fatalf("outcome = %v, want partial_failure", decoded["outcome"])
|
||||
}
|
||||
data, isMap := decoded["data"].(map[string]any)
|
||||
if !isMap {
|
||||
t.Fatalf("partial data must be the three-channel detail object: %s", stdout.String())
|
||||
}
|
||||
for _, channel := range []string{"total", "succeeded", "failed", "unknown"} {
|
||||
if _, ok := data[channel]; !ok {
|
||||
t.Fatalf("partial data missing %q channel: %s", channel, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B25:渲染先进 buffer 再一次性写出 ---
|
||||
|
||||
func TestEmitterRenderErrorLeavesZeroBytes(t *testing.T) {
|
||||
// 渲染失败(marshal 报错)必须不向目标流泄漏任何字节。
|
||||
testseam.Swap(t, &marshalJSONIndent, func(any, string, string) ([]byte, error) {
|
||||
return nil, errEmitterInjected
|
||||
})
|
||||
target := &emitterFailingWriter{acceptN: 64}
|
||||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||||
if err == nil {
|
||||
t.Fatal("Emit must surface render failure")
|
||||
}
|
||||
if len(target.accepted) != 0 {
|
||||
t.Fatalf("render failure leaked %d bytes to the stream: %s", len(target.accepted), target.accepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterWriteFailureLeavesZeroBytes(t *testing.T) {
|
||||
// 写出失败:buffer 先行意味着目标流要么收到完整文档、要么零字节。
|
||||
target := &emitterFailingWriter{} // 第一次 Write 即整体拒绝
|
||||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err == nil {
|
||||
t.Fatal("Emit must surface write failure")
|
||||
}
|
||||
if len(target.accepted) != 0 {
|
||||
t.Fatalf("write failure leaked %d bytes", len(target.accepted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterWriteOnceEmitsSingleWriteOfFullDocument(t *testing.T) {
|
||||
// 「一次性写出」的直接断言:目标流只收到一次 Write,
|
||||
// 且字节与独立重渲染结果逐字节一致(无分块流式拼接)。
|
||||
target := &emitterCountingWriter{}
|
||||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
if target.writes != 1 {
|
||||
t.Fatalf("expected exactly one Write call (buffer-first single flush), got %d", target.writes)
|
||||
}
|
||||
var want bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&want, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
if !bytes.Equal(target.buf.Bytes(), want.Bytes()) {
|
||||
t.Fatalf("streamed bytes differ from re-render:\n got: %s\nwant: %s", target.buf.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDetectsShortWrite(t *testing.T) {
|
||||
// writer 谎报成功但只接受一半字节(短写):Emitter 必须报
|
||||
// io.ErrShortWrite,绝不把截断(残缺)JSON 当成功。
|
||||
target := &emitterShortWriter{}
|
||||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Emit on short write = %v, want io.ErrShortWrite", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B29:meta.count 渲染位置(meta 层而非 data 层)---
|
||||
|
||||
func TestEmitterMetaCountRenderedInMetaLayer(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewSuccessEnvelope([]any{
|
||||
map[string]any{"id": "a"},
|
||||
map[string]any{"id": "b"},
|
||||
map[string]any{"id": "c"},
|
||||
})
|
||||
env.Meta = &Meta{Count: NewCount(3)}
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
raw := stdout.String()
|
||||
// count 只允许出现在 meta 层:wire 上 "count" 键恰出现一次。
|
||||
if got := strings.Count(raw, `"count"`); got != 1 {
|
||||
t.Fatalf(`"count" appears %d times on the wire, want exactly 1 (meta layer only): %s`, got, raw)
|
||||
}
|
||||
var decoded struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 3 {
|
||||
t.Fatalf("meta.count = %v, want 3: %s", decoded.Meta.Count, raw)
|
||||
}
|
||||
for i, row := range decoded.Data {
|
||||
if _, hasCount := row["count"]; hasCount {
|
||||
t.Fatalf("data[%d] must not carry count (belongs to meta layer): %s", i, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B30:dry_run 顶层字段注入断言(契约规范 §6 场景6)---
|
||||
|
||||
func TestEmitterDryRunFieldInjectedAndOmitEmpty(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewSuccessEnvelope(map[string]any{"plan": "create app DemoApp"})
|
||||
env.DryRun = true
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit dry-run envelope: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("dry-run success envelope must not touch stderr, got %d bytes", stderr.Len())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
dryRun, isBool := decoded["dry_run"].(bool)
|
||||
if !isBool {
|
||||
t.Fatalf("dry_run must be a JSON boolean, got %v (%T): %s", decoded["dry_run"], decoded["dry_run"], stdout.String())
|
||||
}
|
||||
if !dryRun {
|
||||
t.Fatalf("dry_run = %v, want true (§6 场景6): %s", dryRun, stdout.String())
|
||||
}
|
||||
if okVal, _ := decoded["ok"].(bool); !okVal {
|
||||
t.Fatalf("dry-run preview keeps ok=true (exit 0 semantics): %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDryRunFalseStaysOmitted(t *testing.T) {
|
||||
// dry_run 为 false(非 dry-run)时必须整体缺席(omitempty,不输出 false)。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
raw := stdout.String()
|
||||
if strings.Contains(raw, `"dry_run"`) {
|
||||
t.Fatalf("dry_run=false must be omitted from wire (§2.5 omitempty): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B31:identity 字段(user/app)omitempty 注入断言(契约规范 §2.5)---
|
||||
|
||||
func TestEmitterIdentityInjectedAndOmitEmpty(t *testing.T) {
|
||||
for _, identity := range []string{"user", "app"} {
|
||||
t.Run(identity, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||||
env.Identity = identity
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decoded["identity"] != identity {
|
||||
t.Fatalf("identity = %v, want %q: %s", decoded["identity"], identity, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterIdentityEmptyStaysOmitted(t *testing.T) {
|
||||
// identity 未注入(空串)时字段缺席,wire 中不出现 "identity"。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
if raw := stdout.String(); strings.Contains(raw, `"identity"`) {
|
||||
t.Fatalf("empty identity must be omitted (§2.5 omitempty): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B32:pending 信封出口完整化(ok:true + operation + exit 0 语义标注)---
|
||||
|
||||
func TestEmitterPendingOutletFullFormWithOperationAndExitZero(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
env := NewPendingEnvelope(&OperationInfo{
|
||||
ID: "task_abc",
|
||||
State: "processing",
|
||||
TimedOut: true, // §2.2:轮询超时保持真实 state + timed_out:true,禁止伪装 success
|
||||
NextCommand: "dws wiki task-result --task task_abc",
|
||||
})
|
||||
env.Data = map[string]any{"spaceId": "s_1", "nodeId": "n_9"}
|
||||
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit pending: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("pending envelope keeps stderr empty (§2.2 走 stdout), got %d bytes", stderr.Len())
|
||||
}
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Data any `json:"data"`
|
||||
Meta struct {
|
||||
Operation *OperationInfo `json:"operation"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !decoded.OK || decoded.Outcome != "pending" {
|
||||
t.Fatalf("pending envelope = ok:%v outcome:%q, want ok:true outcome:pending: %s",
|
||||
decoded.OK, decoded.Outcome, stdout.String())
|
||||
}
|
||||
op := decoded.Meta.Operation
|
||||
if op == nil {
|
||||
t.Fatalf("pending envelope must carry meta.operation (§2.2): %s", stdout.String())
|
||||
}
|
||||
if op.State != "processing" || !op.TimedOut {
|
||||
t.Fatalf("operation must keep real state + timed_out:true (§2.2 anti-spoof): got state=%q timed_out=%v", op.State, op.TimedOut)
|
||||
}
|
||||
if op.NextCommand != "dws wiki task-result --task task_abc" {
|
||||
t.Fatalf("next_command = %q, want executable recovery command (§2.2): %s", op.NextCommand, stdout.String())
|
||||
}
|
||||
if decoded.Data == nil {
|
||||
t.Fatalf("pending envelope data payload lost: %s", stdout.String())
|
||||
}
|
||||
// exit 0 语义标注(B32 与 B33 的耦合点):异步受理 = ok:true → exit 0。
|
||||
if code := ExitCodeForEnvelope(env); code != 0 {
|
||||
t.Fatalf("pending envelope exit code = %d, want 0 (§1/§4/I2)", code)
|
||||
}
|
||||
// 非 json format 下 pending 同样走 stdout(数据通道),stderr 保持零字节。
|
||||
var outTable, errTable bytes.Buffer
|
||||
if err := NewEmitter(&outTable, &errTable, FormatTable, "", "").Emit(env); err != nil {
|
||||
t.Fatalf("Emit pending under -f table: %v", err)
|
||||
}
|
||||
if errTable.Len() != 0 {
|
||||
t.Fatalf("pending under -f table must keep stderr empty, got %d bytes", errTable.Len())
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("pending envelope must pass Validate(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B33:ExitCodeForEnvelope 的 outcome→exit code 映射 ---
|
||||
|
||||
func TestExitCodeForEnvelopeOutcomeCategoryMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env *Envelope
|
||||
want int
|
||||
}{
|
||||
{"success → 0", NewSuccessEnvelope(map[string]any{"id": "x"}), 0},
|
||||
{"pending → 0", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), 0},
|
||||
{"partial_failure → 7", newPartialEnvelopeFixture(), 7},
|
||||
{"failure api → 1", NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018}), 1},
|
||||
{"failure auth → 2", NewFailureEnvelope(&ErrorInfo{Type: "auth"}), 2},
|
||||
{"failure validation → 3", NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"}), 3},
|
||||
{"failure discovery → 6", NewFailureEnvelope(&ErrorInfo{Type: "discovery"}), 6},
|
||||
{"failure internal → 5", NewFailureEnvelope(&ErrorInfo{Type: "internal"}), 5},
|
||||
{"failure unknown type → 5 (internal fallback)", NewFailureEnvelope(&ErrorInfo{Type: "mystery"}), 5},
|
||||
// confirmation 子类 → 3(AC-13;subtype 优先于 type,挂在任何 type 下恒 3)
|
||||
{"failure confirmation subtype on api → 3",
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "confirmation_required",
|
||||
Actions: []string{"dws dev app delete --app-id a1 --yes"}}), 3},
|
||||
{"failure confirmation subtype on validation → 3",
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"}), 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := ExitCodeForEnvelope(c.env); got != c.want {
|
||||
t.Fatalf("ExitCodeForEnvelope = %d, want %d", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForEnvelopeNilEnvelopeIsInternal(t *testing.T) {
|
||||
// nil 信封与 WriteEnvelopeTo 兜底的 internal 类 failure 信封同源(B208)。
|
||||
if got := ExitCodeForEnvelope(nil); got != 5 {
|
||||
t.Fatalf("ExitCodeForEnvelope(nil) = %d, want 5 (internal-class fallback)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForEnvelopeIgnoresTamperedOKAndInvalidOutcome(t *testing.T) {
|
||||
// 只看 outcome(I1 同口径):被篡改 OK 字段的失败信封仍非零。
|
||||
tampered := &Envelope{OK: true, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}
|
||||
if got := ExitCodeForEnvelope(tampered); got == 0 {
|
||||
t.Fatal("ExitCodeForEnvelope must ignore tampered OK field (outcome=failure is never exit 0)")
|
||||
}
|
||||
// 非法 outcome 属框架缺陷 → internal(5),不得落入 0。
|
||||
invalid := &Envelope{OK: true, Outcome: "succeeded"}
|
||||
if got := ExitCodeForEnvelope(invalid); got != 5 {
|
||||
t.Fatalf("ExitCodeForEnvelope(invalid outcome) = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B34:I2 穷举断言——exit 0 ⇔ ok(对 ExitCodeForEnvelope 全组合)---
|
||||
|
||||
func TestInvariantI2ExhaustiveExitZeroIffOK(t *testing.T) {
|
||||
// I2:exit code == 0 ⇔ ok == true。穷举全部规范 outcome(合法装配形态,
|
||||
// 满足 I3)+ nil 信封 + 非法 outcome + 全部 failure 类别细分。
|
||||
legal := []struct {
|
||||
outcome Outcome
|
||||
env *Envelope
|
||||
}{
|
||||
{OutcomeSuccess, NewSuccessEnvelope(map[string]any{"id": "x"})},
|
||||
{OutcomePending, NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"})},
|
||||
{OutcomePartialFailure, newPartialEnvelopeFixture()},
|
||||
{OutcomeFailure, NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"})},
|
||||
}
|
||||
if len(legal) != 4 {
|
||||
t.Fatalf("I2 exhaustive table must cover exactly the four canonical outcomes, got %d", len(legal))
|
||||
}
|
||||
for _, c := range legal {
|
||||
wantOK := c.outcome == OutcomeSuccess || c.outcome == OutcomePending // I1
|
||||
code := ExitCodeForEnvelope(c.env)
|
||||
if (code == 0) != wantOK {
|
||||
t.Fatalf("outcome=%q: exit=%d but ok=%v (I2: exit 0 iff ok)", c.outcome, code, wantOK)
|
||||
}
|
||||
if (c.env.IsOK() != wantOK) || ((code == 0) != c.env.IsOK()) {
|
||||
t.Fatalf("outcome=%q: IsOK()=%v exit=%d disagree (I1/I2 cross-check)", c.outcome, c.env.IsOK(), code)
|
||||
}
|
||||
}
|
||||
|
||||
// failure 全类别细分(api/auth/validation/discovery/internal/unknown/无明细)
|
||||
// 无一 exit 0:ok=false 分支的反向穷举。
|
||||
failureVariants := []*Envelope{
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "api"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "auth"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "validation"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "discovery"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "internal"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "mystery"}),
|
||||
NewFailureEnvelope(&ErrorInfo{Subtype: "confirmation_required"}), // type 缺席 + confirmation 子类:仍 3,非 0
|
||||
}
|
||||
for i, env := range failureVariants {
|
||||
if got := ExitCodeForEnvelope(env); got == 0 {
|
||||
t.Fatalf("failure variant[%d] must never be exit 0 (I2)", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 边界组合:nil 信封与非法 outcome 同样非零(ok=false 语义)。
|
||||
if ExitCodeForEnvelope(nil) == 0 {
|
||||
t.Fatal("nil envelope must never be exit 0 (I2)")
|
||||
}
|
||||
if ExitCodeForEnvelope(&Envelope{OK: true, Outcome: "succeeded"}) == 0 {
|
||||
t.Fatal("invalid outcome must never be exit 0 (I2)")
|
||||
}
|
||||
// partial_failure 专用码精确断言(非零且恰为 7,§4)。
|
||||
if got := ExitCodeForEnvelope(newPartialEnvelopeFixture()); got != 7 {
|
||||
t.Fatalf("partial_failure exit code = %d, want dedicated 7 (§4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B32/B33 联动:Emitter 流路由与 ExitCodeForEnvelope 一致 ---
|
||||
|
||||
func TestEmitterStreamRoutingConsistentWithExitCode(t *testing.T) {
|
||||
// Emit 的流选择与退出码语义一一对应(exit 0 语义标注的行为端):
|
||||
// - ok:true(success/pending)→ stdout + exit 0(I2);
|
||||
// - partial_failure(ok:false)→ stdout(§2.3 stderr 零输出)+ exit 7;
|
||||
// - failure(含 nil 降级)→ stderr + exit 非零,stdout 严格零字节。
|
||||
cases := []struct {
|
||||
name string
|
||||
env *Envelope
|
||||
wantStdout bool // 信封数据通道是否落 stdout
|
||||
wantExitZero bool // I2:exit 0 ⇔ ok
|
||||
}{
|
||||
{"success", NewSuccessEnvelope(map[string]any{"id": "x"}), true, true},
|
||||
{"pending", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), true, true},
|
||||
{"partial_failure", newPartialEnvelopeFixture(), true, false},
|
||||
{"failure", NewFailureEnvelope(&ErrorInfo{Type: "api"}), false, false},
|
||||
{"nil→fallback failure", nil, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(c.env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
code := ExitCodeForEnvelope(c.env)
|
||||
if (code == 0) != c.wantExitZero {
|
||||
t.Fatalf("exit code = %d, wantExitZero=%v (I2)", code, c.wantExitZero)
|
||||
}
|
||||
if c.wantStdout {
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("stdout-routed envelope must carry payload on stdout")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stdout-routed envelope must keep stderr empty (§2.3/§5.1), got %d bytes", stderr.Len())
|
||||
}
|
||||
} else {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must leave stdout empty, got %d bytes", stdout.Len())
|
||||
}
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("failure envelope must be emitted on stderr")
|
||||
}
|
||||
}
|
||||
// partial_failure 专用码精确断言(§4):stdout 通道但非零。
|
||||
if c.env != nil && c.env.Outcome == OutcomePartialFailure && code != 7 {
|
||||
t.Fatalf("partial_failure exit code = %d, want dedicated 7", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- B208:nil 兜底信封 I3 合法化(轮6裁决⑨)---
|
||||
|
||||
func TestNilFallbackEnvelopeCarriesInternalErrorInfo(t *testing.T) {
|
||||
// 兜底信封必须携带 internal 类 ErrorInfo(框架内部缺陷),
|
||||
// 自身通过 Validate()——不得再是被校验器拒绝的形态。
|
||||
env := nilFallbackEnvelope()
|
||||
if env.Error == nil {
|
||||
t.Fatal("nil fallback envelope must carry ErrorInfo (I3: error 非空 ⇔ failure)")
|
||||
}
|
||||
if env.Error.Type != "internal" {
|
||||
t.Fatalf("fallback error.type = %q, want internal", env.Error.Type)
|
||||
}
|
||||
if env.Error.Message != "nil envelope: framework fallback" {
|
||||
t.Fatalf("fallback error.message = %q, want %q", env.Error.Message, "nil envelope: framework fallback")
|
||||
}
|
||||
if env.OK || env.Outcome != OutcomeFailure {
|
||||
t.Fatalf("fallback envelope = ok:%v outcome:%q, want ok:false outcome:failure", env.OK, env.Outcome)
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeToNilEnvelopeEmitsLegalFailure(t *testing.T) {
|
||||
// WriteEnvelopeTo 的 nil 兜底:不 panic、必产合法 failure,
|
||||
// error 明细在 wire 上可解码且类型/消息正确。
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, nil, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(nil) must not fail: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error *ErrorInfo `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("fallback envelope is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded.OK || decoded.Outcome != "failure" {
|
||||
t.Fatalf("fallback = ok:%v outcome:%q, want ok:false outcome:failure: %s", decoded.OK, decoded.Outcome, buf.String())
|
||||
}
|
||||
if decoded.Error == nil {
|
||||
t.Fatalf("fallback envelope must carry error detail on wire: %s", buf.String())
|
||||
}
|
||||
if decoded.Error.Type != "internal" || decoded.Error.Message != "nil envelope: framework fallback" {
|
||||
t.Fatalf("fallback error = %+v, want internal / nil envelope: framework fallback", decoded.Error)
|
||||
}
|
||||
// 兜底信封与 ExitCodeForEnvelope 同源:internal → 5(非零,I2)。
|
||||
if code := ExitCodeForEnvelope(nil); code == 0 {
|
||||
t.Fatal("nil fallback must map to non-zero exit code")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
// Phase C 收尾(B41/B42;契约规范 §5.2)。落盘策略:轮8裁决⑩新文件——
|
||||
// 不编辑 emitter_phase_c_test.go 既有断言。
|
||||
//
|
||||
// 既有覆盖对照:raw≠json 逐字节差异与 raw=writeRaw(data) 透传已由
|
||||
// TestRawPassThroughEnvelopeDiffersFromJSONByteForByte 覆盖;jq 优先于
|
||||
// -f table 已由 TestJQOverridesFormatAndEvaluatesFullEnvelope 覆盖。
|
||||
// 本文件补全:raw 适用域(仅 stdout 数据通道)+ raw×fields / raw×jq
|
||||
// 穷举断言(B41),以及 jq × 全六格式穷举 + jq 对 fields 的优先级(B42)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- B41:-f raw 透传完整语义——适用域穷举 ---
|
||||
|
||||
func TestRawDispatchScopeIsStdoutDataChannelOnly(t *testing.T) {
|
||||
// §5.2 分发矩阵的适用域仅 stdout 数据通道(轮8裁决⑪):携带数据载荷的
|
||||
// outcome(success/pending/partial_failure)在 raw 下透传 data;failure
|
||||
// 信封即使被配置为 raw 也恒以完整 JSON 走 stderr、stdout 严格零字节。
|
||||
cases := []struct {
|
||||
name string
|
||||
env *Envelope
|
||||
}{
|
||||
{"success", phaseCListFixture()},
|
||||
{"pending", newRawPendingFixture()},
|
||||
{"partial_failure", newRawPartialFixture(t)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatRaw, "", "")
|
||||
if err := em.Emit(c.env); err != nil {
|
||||
t.Fatalf("Emit raw: %v", err)
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("data-carrying envelope under raw must emit on stdout")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("data channel must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
// raw 输出不含信封外壳键(透传的是 data 载荷本身)。
|
||||
if strings.Contains(stdout.String(), `"outcome"`) {
|
||||
t.Fatalf("raw must pass through data only, no envelope wrapper: %s", stdout.String())
|
||||
}
|
||||
// 透传等价于 writeRaw(data)(与分发点单一渲染一致)。
|
||||
var direct bytes.Buffer
|
||||
if err := Write(&direct, FormatRaw, c.env.Data); err != nil {
|
||||
t.Fatalf("Write(raw, data): %v", err)
|
||||
}
|
||||
if !bytes.Equal(stdout.Bytes(), direct.Bytes()) {
|
||||
t.Fatalf("raw stdout must be verbatim writeRaw(data):\n got: %s\nwant: %s", stdout.String(), direct.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
// failure 信封:raw 配置被绕过(轮8裁决⑪)——stderr 完整 JSON、stdout 零。
|
||||
t.Run("failure bypasses raw", func(t *testing.T) {
|
||||
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 40001, Message: "boom"})
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatRaw, "id", ".data")
|
||||
if err := em.Emit(failure); err != nil {
|
||||
t.Fatalf("Emit raw failure: %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must leave stdout empty under any format, got %d bytes", stdout.Len())
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("failure under raw must stay full JSON on stderr: %v\n%s", err, stderr.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" || decoded["error"] == nil {
|
||||
t.Fatalf("failure envelope lost wire-stable fields under raw bypass: %s", stderr.String())
|
||||
}
|
||||
})
|
||||
// 分发点层同判:WriteEnvelopeTo(无流路由签名)下 failure + raw 也恒为
|
||||
// 完整 JSON 信封——raw 的适用域不含失败通道。
|
||||
t.Run("dispatch point also bypasses raw for failure", func(t *testing.T) {
|
||||
failure := NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"})
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, failure, FormatRaw, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo failure raw: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("failure under raw at dispatch point must be full JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" {
|
||||
t.Fatalf("outcome = %v: %s", decoded["outcome"], buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newRawPendingFixture() *Envelope {
|
||||
// pending 形态携带 data 载荷:raw 适用域覆盖所有携带数据的 outcome。
|
||||
env := NewPendingEnvelope(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||
env.Data = map[string]any{"taskId": "t_1", "accepted": true}
|
||||
return env
|
||||
}
|
||||
|
||||
func newRawPartialFixture(t *testing.T) *Envelope {
|
||||
t.Helper()
|
||||
pd, err := NewPartialData(2,
|
||||
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPartialData: %v", err)
|
||||
}
|
||||
return NewPartialEnvelope(pd)
|
||||
}
|
||||
|
||||
// --- B41:raw × fields / raw × jq 穷举 ---
|
||||
|
||||
func TestRawTimesFieldsProjectsDataBeforePassThrough(t *testing.T) {
|
||||
// --fields 先投影数据载荷再按 format 分发(成功通道联动):raw 下
|
||||
// fields 作用于 data 载荷本身——透传的是投影后的载荷,信封外壳仍缺席。
|
||||
env := phaseCListFixture()
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatRaw, "id", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(raw, fields=id): %v", err)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("raw×fields output must be decodable JSON of projected data: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("rows = %d, want 3: %s", len(rows), buf.String())
|
||||
}
|
||||
for i, row := range rows {
|
||||
if len(row) != 1 {
|
||||
t.Fatalf("row %d must be projected to id only: %v", i, row)
|
||||
}
|
||||
if _, has := row["id"]; !has {
|
||||
t.Fatalf("row %d lost id after projection: %v", i, row)
|
||||
}
|
||||
}
|
||||
// 信封外壳键不出现(raw 恒只透传 data 通道)。
|
||||
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"meta"`) {
|
||||
t.Fatalf("raw×fields must not emit envelope wrapper: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawTimesJQJQWinsAndEvaluatesFullEnvelope(t *testing.T) {
|
||||
// --jq 优先于 format(含 raw):对**完整信封**求值后直接输出结果——
|
||||
// raw×jq 下 jq 表达式可触及信封外壳键(.ok/.outcome),证明求值对象是
|
||||
// 完整信封而非 raw 的 data 载荷。
|
||||
for _, tc := range []struct {
|
||||
jq string
|
||||
want string
|
||||
}{
|
||||
{".ok", "true"},
|
||||
{".outcome", `"success"`},
|
||||
{".meta.pagination.next_token", `"cursor_xyz"`},
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "", tc.jq); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(raw, jq=%q): %v", tc.jq, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != tc.want {
|
||||
t.Fatalf("raw×jq %q = %q, want %q (jq must win over raw on the full envelope)", tc.jq, got, tc.want)
|
||||
}
|
||||
}
|
||||
// raw×jq×fields 三者同设:jq 仍恒赢——输出是 jq 求值结果,fields 不得
|
||||
// 先行改写求值输入(jq 优先级链在分发点入口,fields 投影只在无 jq 分支)。
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "id", ".outcome"); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(raw, fields+jq): %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != `"success"` {
|
||||
t.Fatalf("raw×fields×jq = %q, want \"success\" (jq wins over both)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawStringPayloadVerbatimPassThrough(t *testing.T) {
|
||||
// raw 完整语义的字符串分支:data 为字符串时 raw 输出净化后的原文本
|
||||
// (writeRaw 字符串分支),不是 JSON 引号包裹——这是「透传」的第二形态。
|
||||
env := NewSuccessEnvelope("hello raw world")
|
||||
var rawBuf, jsonBuf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
|
||||
}
|
||||
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(json): %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(rawBuf.String()); got != "hello raw world" {
|
||||
t.Fatalf("raw string payload = %q, want verbatim text", got)
|
||||
}
|
||||
if strings.Contains(rawBuf.String(), `"hello`) {
|
||||
t.Fatalf("raw string branch must not JSON-quote the payload: %s", rawBuf.String())
|
||||
}
|
||||
// json 形态仍是完整信封(两形态互不污染)。
|
||||
if !strings.Contains(jsonBuf.String(), `"outcome"`) {
|
||||
t.Fatalf("json must keep the envelope wrapper: %s", jsonBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- B42:--jq 优先于 format——全六格式穷举对完整信封求值 ---
|
||||
|
||||
func TestJQOverridesAllSixFormatsEvaluatingFullEnvelope(t *testing.T) {
|
||||
// jq 优先级对 §5.2 矩阵全部六格式成立:无论 format 是什么,输出恒为
|
||||
// jq 对完整信封的求值结果——matrix 各行(table/pretty/csv/ndjson/raw/
|
||||
// json)全部被 jq 短路。
|
||||
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
|
||||
for _, format := range formats {
|
||||
t.Run("format="+string(format), func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), format, "", ".outcome"); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(%s, jq=.outcome): %v", format, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != `"success"` {
|
||||
t.Fatalf("format %s × jq = %q, want \"success\" (jq must win over every format)", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Emitter 路径同判(双向出口共用分发点):-f csv + jq 下 stdout 仍是
|
||||
// jq 结果而非裸记录。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatCSV, "", ".meta.count")
|
||||
if err := em.Emit(phaseCListFixture()); err != nil {
|
||||
t.Fatalf("Emit csv+jq: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "3" {
|
||||
t.Fatalf("Emitter csv×jq = %q, want 3", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("jq path must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJQWinsOverFieldsAsWell(t *testing.T) {
|
||||
// jq 与 fields 同设:jq 恒赢,fields 不得改写 jq 的求值输入。
|
||||
// 求值对象是可触及 meta 的完整信封——若 fields 先行投影,.meta 将缺席。
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatJSON, "id", ".meta.count"); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(fields+jq): %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "3" {
|
||||
t.Fatalf("fields×jq = %q, want 3 (jq must win over fields on the full envelope)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJQInvalidExpressionFailsCleanly(t *testing.T) {
|
||||
// jq 表达式非法时求值失败即报错(validation 类)——不静默降级回 format
|
||||
// 输出,也不泄漏半截渲染(buffer-first)。
|
||||
var buf bytes.Buffer
|
||||
err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data[")
|
||||
if err == nil {
|
||||
t.Fatalf("invalid jq expression must fail, got output: %s", buf.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "jq") {
|
||||
t.Fatalf("error must name jq: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("invalid jq must leave zero bytes on the target (buffer-first): %s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// phaseCListFixture 是三格式(table/csv/ndjson)共用的列表载荷:
|
||||
// 3 条同构对象记录,配 meta.count 与 meta.pagination 以便同时断言
|
||||
// 「人读摘要」与「分页诊断行」两条分发行为。
|
||||
func phaseCListFixture() *Envelope {
|
||||
env := NewSuccessEnvelope([]any{
|
||||
map[string]any{"id": "a", "name": "alpha"},
|
||||
map[string]any{"id": "b", "name": "beta"},
|
||||
map[string]any{"id": "c", "name": "gamma"},
|
||||
})
|
||||
env.Meta = &Meta{
|
||||
Count: NewCount(3),
|
||||
Pagination: &Pagination{
|
||||
EndpointExhausted: false,
|
||||
Pages: 2,
|
||||
Items: 60,
|
||||
NextToken: "cursor_xyz",
|
||||
},
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// --- B35:format 值归一化(trim/小写)——normalizeFormat 既有实现的补差断言 ---
|
||||
|
||||
func TestNormalizeFormatTrimAndCaseExhaustive(t *testing.T) {
|
||||
// 六格式 × (原始小写 / 大写 / 混合大小写 + 前后空白)全部命中对应常量;
|
||||
// 归一化规则 trim + 小写,与 ResolveFormat/ParseFormat 单一事实源。
|
||||
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
|
||||
for _, want := range formats {
|
||||
base := string(want)
|
||||
for _, raw := range []string{
|
||||
base,
|
||||
strings.ToUpper(base),
|
||||
" " + strings.ToUpper(base[:1]) + base[1:] + "\t",
|
||||
} {
|
||||
if got := normalizeFormat(raw, FormatJSON); got != want {
|
||||
t.Fatalf("normalizeFormat(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
// ParseFormat 薄包装与 normalizeFormat 结果恒一致(单一事实源)。
|
||||
if got := ParseFormat(raw, FormatJSON); got != want {
|
||||
t.Fatalf("ParseFormat(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 空串 = 未指定 → fallback;未知值 → fallback(B36 降级的归一化前提)。
|
||||
if got := normalizeFormat("", FormatTable); got != FormatTable {
|
||||
t.Fatalf("normalizeFormat empty = %q, want fallback table", got)
|
||||
}
|
||||
if got := normalizeFormat(" \t ", FormatCSV); got != FormatCSV {
|
||||
t.Fatalf("normalizeFormat whitespace-only = %q, want fallback csv", got)
|
||||
}
|
||||
if got := normalizeFormat("yaml", FormatJSON); got != FormatJSON {
|
||||
t.Fatalf("normalizeFormat unknown = %q, want fallback json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B36:未知 format 降级 + stderr warning(不崩不静默,AC-09)---
|
||||
|
||||
func TestResolveFormatWithWarningKnownValuesSilent(t *testing.T) {
|
||||
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
|
||||
for _, want := range formats {
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
if err := cmd.Flags().Set("format", string(want)); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
got, warning := resolveFormatWithWarning(cmd, FormatJSON)
|
||||
if got != want {
|
||||
t.Fatalf("resolveFormatWithWarning(%q) = %q, want %q", want, got, want)
|
||||
}
|
||||
if warning != "" {
|
||||
t.Fatalf("known format %q must not warn, got %q", want, warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFormatWithWarningUnknownDegradesWithWarning(t *testing.T) {
|
||||
for _, fallback := range []Format{FormatJSON, FormatTable} {
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
if err := cmd.Flags().Set("format", "yaml"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
got, warning := resolveFormatWithWarning(cmd, fallback)
|
||||
if got != fallback {
|
||||
t.Fatalf("unknown format must degrade to fallback %q, got %q", fallback, got)
|
||||
}
|
||||
if warning == "" {
|
||||
t.Fatal("unknown format must produce a warning (AC-09: no silent swallow)")
|
||||
}
|
||||
if !strings.Contains(warning, "yaml") || !strings.Contains(warning, string(fallback)) {
|
||||
t.Fatalf("warning must name the unknown value and the fallback target: %q", warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFormatWithWarningEmptyAndMissingSilent(t *testing.T) {
|
||||
// 空值 = 未指定(走 fallback 是正常默认路径),flag 缺席同:均不报警。
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
if got, warning := resolveFormatWithWarning(cmd, FormatRaw); got != FormatRaw || warning != "" {
|
||||
t.Fatalf("empty format = (%q, %q), want (raw, silent)", got, warning)
|
||||
}
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
if got, warning := resolveFormatWithWarning(cmd2, FormatRaw); got != FormatRaw || warning != "" {
|
||||
t.Fatalf("missing flag = (%q, %q), want (raw, silent)", got, warning)
|
||||
}
|
||||
if got, warning := resolveFormatWithWarning(nil, FormatJSON); got != FormatJSON || warning != "" {
|
||||
t.Fatalf("nil cmd = (%q, %q), want (json, silent)", got, warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFormatKeepsWarningFreeReturnContract(t *testing.T) {
|
||||
// ResolveFormat(既有导出 API)行为不变:只返回 format,未知值静默降级。
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
if err := cmd.Flags().Set("format", "bogus"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if got := ResolveFormat(cmd, FormatJSON); got != FormatJSON {
|
||||
t.Fatalf("ResolveFormat unknown = %q, want json fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeUnknownFormatDegradesToJSONWithStderrWarning(t *testing.T) {
|
||||
// 端到端:未知 --format 不崩不静默——stdout 仍为完整 json 信封(降级
|
||||
// 生效,数据不丢),stderr 恰有一条 warning 点名未知值与降级目标。
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Flags().Set("format", "yaml"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
|
||||
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
|
||||
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope with unknown format must not crash: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout must degrade to a full json envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decoded["outcome"] != "success" {
|
||||
t.Fatalf("degraded envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
|
||||
}
|
||||
data, _ := decoded["data"].(map[string]any)
|
||||
if data == nil || data["name"] != "DemoApp" {
|
||||
t.Fatalf("degraded envelope lost data payload: %s", stdout.String())
|
||||
}
|
||||
if got := strings.Count(stderr.String(), "[WARN]"); got != 1 {
|
||||
t.Fatalf("stderr must carry exactly one warning line, got %d: %s", got, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "yaml") || !strings.Contains(stderr.String(), "json") {
|
||||
t.Fatalf("warning must name unknown value and fallback: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeKnownFormatLeavesStderrSilent(t *testing.T) {
|
||||
// 已知 format 不产生 warning(stderr 零字节),防 B36 误伤正常路径。
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Flags().Set("format", "table"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if err := WriteEnvelope(cmd, NewSuccessEnvelope(map[string]any{"name": "x"}), FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("known format must not warn, stderr = %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- B37:信封渲染接统一 format 分发点(WS1 改动点4)---
|
||||
|
||||
func TestRenderEnvelopeSingleDispatchPointEmitterAndWriterAgree(t *testing.T) {
|
||||
// 分发点唯一性:Emitter 路径与 WriteEnvelopeTo 路径对同一信封 + 同一
|
||||
// format 逐字节一致——两条出口共用 renderEnvelope,不存在第二套渲染。
|
||||
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
|
||||
for _, format := range formats {
|
||||
env := phaseCListFixture()
|
||||
var direct bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&direct, env, format, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(%s): %v", format, err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := NewEmitter(&stdout, &stderr, format, "", "").Emit(env); err != nil {
|
||||
t.Fatalf("Emit(%s): %v", format, err)
|
||||
}
|
||||
if !bytes.Equal(direct.Bytes(), stdout.Bytes()) {
|
||||
t.Fatalf("format %s: Emitter and WriteEnvelopeTo disagree:\n writer: %s\nemitter: %s",
|
||||
format, direct.String(), stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEnvelopeBufferFirstZeroLeakOnRenderFailure(t *testing.T) {
|
||||
// §5.2 末条对整个分发点成立:渲染失败(不可序列化载荷 chan)
|
||||
// 不向目标流泄漏任何字节——渲染段在写出前整体失败。
|
||||
unrenderable := NewSuccessEnvelope(map[string]any{"bad": make(chan int)})
|
||||
for _, format := range []Format{FormatJSON, FormatTable, FormatCSV, FormatNDJSON} {
|
||||
target := &emitterFailingWriter{acceptN: 64}
|
||||
err := WriteEnvelopeTo(target, unrenderable, format, "", "")
|
||||
if err == nil {
|
||||
t.Fatalf("format %s must surface render failure", format)
|
||||
}
|
||||
if len(target.accepted) != 0 {
|
||||
t.Fatalf("format %s leaked %d bytes on render failure: %s", format, len(target.accepted), target.accepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEnvelopeFailureEnvelopeBypassesFormatAtDispatchPoint(t *testing.T) {
|
||||
// 轮8裁决⑪在分发点内落地:failure 信封即使被调用方以 table/jq 配置
|
||||
// 送入 renderEnvelope(WriteEnvelopeTo 层无流路由),也恒输出完整 JSON
|
||||
// 信封——format/jq/fields 不得抹掉 §2.4 wire-stable 字段组。
|
||||
var buf bytes.Buffer
|
||||
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018, Retryable: true})
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatTable, "id", ".data"); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo failure under table/jq: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("failure envelope must stay full JSON regardless of format: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" {
|
||||
t.Fatalf("outcome = %v, want failure: %s", decoded["outcome"], buf.String())
|
||||
}
|
||||
errObj, ok := decoded["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("failure envelope lost error detail: %s", buf.String())
|
||||
}
|
||||
for _, key := range []string{"type", "subtype", "code", "retryable"} {
|
||||
if _, has := errObj[key]; !has {
|
||||
t.Fatalf("wire-stable key %q lost under format/jq bypass: %s", key, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B38:-f json 输出完整信封(唯一 JSON 契约)---
|
||||
|
||||
func TestWriteEnvelopeJSONIsTheOnlyJSONContract(t *testing.T) {
|
||||
// json 与空 format 等价:stdout 恰为一个完整信封 JSON 文档,
|
||||
// ok/outcome/data/meta 外壳键齐备(唯一 JSON 契约,§5.2 首行)。
|
||||
for _, format := range []Format{FormatJSON, ""} {
|
||||
var buf bytes.Buffer
|
||||
env := phaseCListFixture()
|
||||
if err := WriteEnvelopeTo(&buf, env, format, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(%q): %v", format, err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("format %q stdout is not valid JSON: %v\n%s", format, err, buf.String())
|
||||
}
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||
}
|
||||
if decoded["outcome"] != "success" {
|
||||
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||
}
|
||||
for _, key := range []string{"data", "meta"} {
|
||||
if _, has := decoded[key]; !has {
|
||||
t.Fatalf("full envelope must carry %q: %s", key, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeJSONFieldsProjectionFiltersBusinessData(t *testing.T) {
|
||||
// --fields 与 json 联动时只投影业务载荷,统一信封及分页元数据必须保留。
|
||||
var buf bytes.Buffer
|
||||
env := phaseCListFixture()
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "name", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
var decoded Envelope
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("fields=name output is not an envelope: %v\n%s", err, buf.String())
|
||||
}
|
||||
if !decoded.OK || decoded.Outcome != OutcomeSuccess || decoded.Meta == nil || decoded.Meta.Pagination == nil || decoded.Meta.Pagination.NextToken != "cursor_xyz" {
|
||||
t.Fatalf("stable envelope metadata was lost: %+v", decoded)
|
||||
}
|
||||
items, ok := decoded.Data.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("projected data type = %T", decoded.Data)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("projected items = %#v", items)
|
||||
}
|
||||
for _, item := range items {
|
||||
row, ok := item.(map[string]any)
|
||||
if !ok || len(row) != 1 || row["name"] == nil {
|
||||
t.Fatalf("projected row = %#v", item)
|
||||
}
|
||||
}
|
||||
// 投影不得改写原信封的 data。
|
||||
data, ok := env.Data.([]any)
|
||||
if !ok || len(data[0].(map[string]any)) <= 1 {
|
||||
t.Fatalf("source envelope data mutated: %#v", env.Data)
|
||||
}
|
||||
|
||||
// 未知 Format 常量的 json 兜底分支(归一化后理论不可达)遵循同一
|
||||
// --fields 兼容语义:投影业务载荷而不是信封顶层键。
|
||||
buf.Reset()
|
||||
if err := WriteEnvelopeTo(&buf, NewSuccessEnvelope(map[string]any{"name": "DemoApp", "id": "1"}), Format("bogus"), "name", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(bogus format): %v", err)
|
||||
}
|
||||
if want := "{\n \"name\": \"DemoApp\"\n}\n"; buf.String() != want {
|
||||
t.Fatalf("bogus format fields=name output = %q, want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJQOverridesFormatAndEvaluatesFullEnvelope(t *testing.T) {
|
||||
// --jq 优先于 format(§5.2 矩阵行):-f table 下 jq 仍对**完整信封**
|
||||
// 求值——.data 可取业务载荷、.ok/.outcome 可取框架字段。
|
||||
for _, tc := range []struct {
|
||||
jq string
|
||||
want string
|
||||
}{
|
||||
{".ok", "true"},
|
||||
{".outcome", `"success"`},
|
||||
{".meta.pagination.next_token", `"cursor_xyz"`},
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", tc.jq); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo jq=%q: %v", tc.jq, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != tc.want {
|
||||
t.Fatalf("jq %q = %q, want %q (jq must win over -f table on the full envelope)", tc.jq, got, tc.want)
|
||||
}
|
||||
}
|
||||
// .data 提取业务载荷:输出不再是信封(无 outcome 外壳键)。
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data"); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo jq=.data: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), `"outcome"`) {
|
||||
t.Fatalf("jq=.data output must not carry the envelope wrapper: %s", buf.String())
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("jq=.data must emit the bare data payload: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("jq=.data rows = %d, want 3", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// --- B39:-f table/pretty 输出业务数据 + 人读摘要(不输出信封外壳)---
|
||||
|
||||
func TestTableRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(table): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// 数据行可见。
|
||||
for _, want := range []string{"alpha", "beta", "gamma"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("table output missing data row %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// 人读摘要:count 与分页摘要(wire 字段名 snake_case,两态显式——
|
||||
// endpoint_exhausted:false 也输出,它是「可续跑」语义)。
|
||||
if !strings.Contains(out, "count: 3") {
|
||||
t.Fatalf("table output missing count summary:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pagination: endpoint_exhausted: false, pages: 2, items: 60, next_token: cursor_xyz") {
|
||||
t.Fatalf("table output missing pagination summary:\n%s", out)
|
||||
}
|
||||
// 不输出信封外壳键。
|
||||
for _, forbidden := range []string{`"ok"`, `"outcome"`, `"endpoint_exhausted": `} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Fatalf("table output must not emit envelope wrapper (%q):\n%s", forbidden, out)
|
||||
}
|
||||
}
|
||||
// stdout 不是 JSON 文档(人读视图)。
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Fatalf("table output should not be a JSON document:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableWithoutMetaEmitsNoSummary(t *testing.T) {
|
||||
// 无 meta 时不追加摘要行,不留空壳。
|
||||
var buf bytes.Buffer
|
||||
env := NewSuccessEnvelope([]any{map[string]any{"id": "a"}})
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatTable, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(table): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "count:") || strings.Contains(out, "pagination:") {
|
||||
t.Fatalf("no-meta table must not emit summary lines:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "a") {
|
||||
t.Fatalf("table output missing data:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
|
||||
forceNoColor(t)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatPretty, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(pretty): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"alpha", "count: 3", "endpoint_exhausted: false"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"ok"`, `"outcome"`} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Fatalf("pretty output must not emit envelope wrapper (%q):\n%s", forbidden, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableWithFieldsProjectionNarrowsColumns(t *testing.T) {
|
||||
// --fields 与 table 联动(成功通道):投影先作用于数据载荷再渲染。
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "id", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(table, fields=id): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// id 列保留(值 a/b/c 可见),name 列与其值被投影掉。
|
||||
for _, want := range []string{"id", "│ a", "│ b", "│ c"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("fields=id must keep id column/values (%q):\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "name") || strings.Contains(out, "alpha") {
|
||||
t.Fatalf("fields=id must drop name column and its values:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B40:-f csv/ndjson 裸记录;分页元数据走 stderr 诊断行 ---
|
||||
|
||||
// newPhaseCCmd 构造带 --format flag 且 stdout/stderr 均可捕获的测试命令
|
||||
// (诊断行断言必须走 cmd 出口路径:WriteEnvelopeTo 的 writer 级签名无
|
||||
// stderr 出口,诊断行在该签名下按 io.Discard 处理)。
|
||||
func newPhaseCCmd(t *testing.T, formatValue string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().StringP("format", "f", "", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Flags().Set("format", formatValue); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
return cmd, &stdout, &stderr
|
||||
}
|
||||
|
||||
func TestNDJSONEmitsBareRecordsWithoutEnvelope(t *testing.T) {
|
||||
cmd, stdout, stderr := newPhaseCCmd(t, "ndjson")
|
||||
env := NewSuccessEnvelope([]any{
|
||||
map[string]any{"id": "a"},
|
||||
map[string]any{"id": "b"},
|
||||
map[string]any{"id": "c"},
|
||||
})
|
||||
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope(ndjson): %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("ndjson must emit one bare record per line, got %d:\n%s", len(lines), stdout.String())
|
||||
}
|
||||
for i, line := range lines {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("ndjson line %d is not valid JSON: %v", i, err)
|
||||
}
|
||||
if _, has := rec["outcome"]; has {
|
||||
t.Fatalf("ndjson line %d carries envelope wrapper: %s", i, line)
|
||||
}
|
||||
}
|
||||
// 无 pagination 时 stderr 零诊断行。
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("no-pagination ndjson must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVEmitsBareRecordsWithoutEnvelope(t *testing.T) {
|
||||
cmd, stdout, stderr := newPhaseCCmd(t, "csv")
|
||||
env := NewSuccessEnvelope([]any{
|
||||
map[string]any{"id": "a", "name": "alpha"},
|
||||
map[string]any{"id": "b", "name": "beta"},
|
||||
})
|
||||
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope(csv): %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 3 { // header + 2 records
|
||||
t.Fatalf("csv must emit header + one row per record, got %d lines:\n%s", len(lines), stdout.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], "id") || !strings.Contains(lines[0], "name") {
|
||||
t.Fatalf("csv header missing columns: %s", lines[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"outcome"`) || strings.Contains(stdout.String(), `"ok"`) {
|
||||
t.Fatalf("csv must not emit envelope wrapper keys:\n%s", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("no-pagination csv must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVNDJSONPaginationMetadataGoesToStderrDiagnostic(t *testing.T) {
|
||||
// 分页元数据不进裸记录流,走 stderr 诊断行(§5.2 csv/ndjson 行)。
|
||||
for _, format := range []Format{FormatCSV, FormatNDJSON} {
|
||||
cmd, stdout, stderr := newPhaseCCmd(t, string(format))
|
||||
if err := WriteEnvelope(cmd, phaseCListFixture(), FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope(%s): %v", format, err)
|
||||
}
|
||||
diag := stderr.String()
|
||||
if !strings.Contains(diag, "[pagination]") {
|
||||
t.Fatalf("%s pagination diagnostic missing from stderr: %q", format, diag)
|
||||
}
|
||||
for _, want := range []string{"endpoint_exhausted: false", "next_token: cursor_xyz"} {
|
||||
if !strings.Contains(diag, want) {
|
||||
t.Fatalf("%s diagnostic missing %q: %q", format, want, diag)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diag, "[pagination]"); got != 1 {
|
||||
t.Fatalf("%s must emit exactly one pagination diagnostic line, got %d: %q", format, got, diag)
|
||||
}
|
||||
// stdout 保持纯裸记录:无诊断行、无信封键、无分页键。
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "[pagination]") || strings.Contains(out, "next_token") || strings.Contains(out, `"outcome"`) {
|
||||
t.Fatalf("%s stdout polluted by diagnostics/envelope:\n%s", format, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterCSVNDJSONDiagnosticRoutedToEmitterStderr(t *testing.T) {
|
||||
// Emitter 路径:诊断行落 Emitter 的 stderr writer(可注入),stdout
|
||||
// 只承载裸记录;流路由纪律不变(success → stdout)。
|
||||
for _, format := range []Format{FormatCSV, FormatNDJSON} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, format, "", "")
|
||||
if err := em.Emit(phaseCListFixture()); err != nil {
|
||||
t.Fatalf("Emit(%s): %v", format, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[pagination]") {
|
||||
t.Fatalf("%s: emitter stderr must carry pagination diagnostic: %q", format, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "[pagination]") {
|
||||
t.Fatalf("%s: stdout must stay bare records:\n%s", format, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFailureCSVKeepsStderrZero(t *testing.T) {
|
||||
// §2.3 特例优先:partial_failure 的 stderr 零输出约束压过 csv/ndjson
|
||||
// 的诊断行通则——即便携带分页元数据也不写诊断行。
|
||||
env := newPartialEnvelopeFixture()
|
||||
env.Meta = &Meta{
|
||||
Count: NewCount(3),
|
||||
Pagination: &Pagination{EndpointExhausted: true},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatCSV, "", "")
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit partial under csv: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("partial_failure must keep stderr zero (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("partial_failure details must stay on stdout (§2.3)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- B43:--json 语义 = --format json 简写(显式 --format 优先)---
|
||||
|
||||
func TestResolveFormatWithJSONShorthandMatrix(t *testing.T) {
|
||||
// 优先级链穷举(契约规范 §5.2:「--json 自动注册为 --format json 简写
|
||||
// (显式 --format 优先)」):显式 --format 非空值恒优先;--format 缺席
|
||||
// 或为空时 --json=true 等价 --format json;两者皆无 → fallback。
|
||||
newCmd := func(formatSet string, formatValue string, jsonSet bool, jsonValue bool) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("format", "", "")
|
||||
cmd.Flags().Bool("json", false, "")
|
||||
if formatSet != "" {
|
||||
if err := cmd.Flags().Set("format", formatValue); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
}
|
||||
if jsonSet {
|
||||
if err := cmd.Flags().Set("json", jsonFlagValue(jsonValue)); err != nil {
|
||||
t.Fatalf("set json: %v", err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
fallback Format
|
||||
want Format
|
||||
}{
|
||||
{"json shorthand alone", newCmd("", "", true, true), FormatTable, FormatJSON},
|
||||
{"explicit format wins over json shorthand", newCmd("set", "table", true, true), FormatJSON, FormatTable},
|
||||
{"explicit raw wins over json shorthand", newCmd("set", "raw", true, true), FormatJSON, FormatRaw},
|
||||
{"empty format does not shadow shorthand", newCmd("set", "", true, true), FormatTable, FormatJSON},
|
||||
{"json=false is not shorthand", newCmd("", "", true, false), FormatTable, FormatTable},
|
||||
{"neither flag", newCmd("", "", false, false), FormatTable, FormatTable},
|
||||
{"unknown format not rescued by json shorthand", newCmd("set", "yaml", true, true), FormatJSON, FormatJSON},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := ResolveFormatWithJSONShorthand(c.cmd, c.fallback); got != c.want {
|
||||
t.Fatalf("ResolveFormatWithJSONShorthand = %q, want %q (fallback=%q)", got, c.want, c.fallback)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := ResolveFormatWithJSONShorthand(nil, FormatRaw); got != FormatRaw {
|
||||
t.Fatalf("nil cmd = %q, want fallback raw", got)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonFlagValue 把 bool 渲染成 pflag.Set 可接受的字符串。
|
||||
func jsonFlagValue(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func TestResolveFormatWithJSONShorthandIgnoresStringTypedJSONFlag(t *testing.T) {
|
||||
// 业务层存在同名 string flag(如 table create --json 载荷参数):
|
||||
// GetBool 失败即跳过,不得被误判为简写。
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().String("json", "", "base JSON payload")
|
||||
if err := cmd.Flags().Set("json", `{"x":1}`); err != nil {
|
||||
t.Fatalf("set json: %v", err)
|
||||
}
|
||||
if got := ResolveFormatWithJSONShorthand(cmd, FormatTable); got != FormatTable {
|
||||
t.Fatalf("string-typed --json must not act as shorthand, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFormatWithJSONShorthandReadsInheritedAndPersistentFlags(t *testing.T) {
|
||||
// --format 继承自 root persistent(与 ResolveFormat 同查找面);
|
||||
// --json 挂在子命令本地亦生效。
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "", "")
|
||||
child := &cobra.Command{Use: "list"}
|
||||
child.Flags().Bool("json", false, "")
|
||||
root.AddCommand(child)
|
||||
if err := root.PersistentFlags().Set("format", "csv"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if err := child.Flags().Set("json", "true"); err != nil {
|
||||
t.Fatalf("set json: %v", err)
|
||||
}
|
||||
// 显式 format(继承)优先于 json 简写。
|
||||
if got := ResolveFormatWithJSONShorthand(child, FormatJSON); got != FormatCSV {
|
||||
t.Fatalf("inherited explicit format must win, got %q", got)
|
||||
}
|
||||
// 清空 format 后 json 简写生效。
|
||||
if err := root.PersistentFlags().Set("format", ""); err != nil {
|
||||
t.Fatalf("reset format: %v", err)
|
||||
}
|
||||
if got := ResolveFormatWithJSONShorthand(child, FormatTable); got != FormatJSON {
|
||||
t.Fatalf("json shorthand = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B209(轮10裁决⑭小批,随 Phase C 同轮收口):ExitCodeForEnvelope 边界
|
||||
// 组合精确值断言 + 与 internal/errors.ExitCode 表同源锁定 ---
|
||||
|
||||
func TestExitCodeForEnvelopeBoundaryCombinationsExactValues(t *testing.T) {
|
||||
// ① failure + Error 缺席(I3 非法的手工形态,构造器不产出)→ internal
|
||||
// 兜底 exit==5,精确值固化(无明细可分支的失败不得假装可恢复)。
|
||||
invalid := &Envelope{OK: false, Outcome: OutcomeFailure}
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatal("failure without Error must be rejected by Validate (I3)")
|
||||
}
|
||||
if got := ExitCodeForEnvelope(invalid); got != 5 {
|
||||
t.Fatalf("failure without Error = exit %d, want 5 (internal fallback)", got)
|
||||
}
|
||||
// ② failure + Error.Type 空串(wire-stable 字段缺失形态)→ 同样 internal 5。
|
||||
emptyType := NewFailureEnvelope(&ErrorInfo{Message: "no type"})
|
||||
if got := ExitCodeForEnvelope(emptyType); got != 5 {
|
||||
t.Fatalf("failure with empty error.type = exit %d, want 5 (internal fallback)", got)
|
||||
}
|
||||
// ③ failure + Error.Type 未知值(不在码表类别内)→ internal 5。
|
||||
unknown := NewFailureEnvelope(&ErrorInfo{Type: "mystery"})
|
||||
if got := ExitCodeForEnvelope(unknown); got != 5 {
|
||||
t.Fatalf("failure with unknown error.type = exit %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForEnvelopeSameSourceAsErrorsExitCode(t *testing.T) {
|
||||
// B33 常量块与 internal/errors/errors.go ExitCode 表同源锁定:逐类别值
|
||||
// 相等,防未来单边漂移(轮10裁决⑬/⑭)。PAT=4 边界注记:PAT 行为授权
|
||||
// 专属码经 ExitCoder 接口路径分发,不出现在信封码表(信封 failure 的
|
||||
// error.type 枚举不含 pat 类别),故不在本锁定表内。
|
||||
cases := []struct {
|
||||
category string
|
||||
err error
|
||||
}{
|
||||
{"api", apperrors.NewAPI("x")},
|
||||
{"auth", apperrors.NewAuth("x")},
|
||||
{"validation", apperrors.NewValidation("x")},
|
||||
{"discovery", apperrors.NewDiscovery("x")},
|
||||
{"internal", apperrors.NewInternal("x")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
want := apperrors.ExitCode(c.err)
|
||||
env := NewFailureEnvelope(&ErrorInfo{Type: c.category})
|
||||
if got := ExitCodeForEnvelope(env); got != want {
|
||||
t.Fatalf("category %q: ExitCodeForEnvelope=%d but errors.ExitCode=%d (code tables drifted)",
|
||||
c.category, got, want)
|
||||
}
|
||||
}
|
||||
// confirmation_required 子类与 validation 共享 rc=3(OQ-1 定案):
|
||||
// runtime 形态 = NewValidation + WithReason(confirmation_required)。
|
||||
confirmErr := apperrors.NewValidation("confirm", apperrors.WithReason("confirmation_required"))
|
||||
confirmCode := apperrors.ExitCode(confirmErr)
|
||||
confirmEnv := NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"})
|
||||
if got := ExitCodeForEnvelope(confirmEnv); got != confirmCode {
|
||||
t.Fatalf("confirmation subclass: ExitCodeForEnvelope=%d but runtime errors.ExitCode=%d", got, confirmCode)
|
||||
}
|
||||
if confirmCode != 3 {
|
||||
t.Fatalf("confirmation/validation shared rc = %d, want 3 (OQ-1)", confirmCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B35~B40 综合:六格式在 WriteEnvelopeTo/Emitter 路径的完整分发表 ---
|
||||
|
||||
func TestRawPassThroughEnvelopeDiffersFromJSONByteForByte(t *testing.T) {
|
||||
// -f raw 对信封的透传:只出数据载荷本身(writeRaw 语义),与 json 的
|
||||
// 完整信封逐字节不同——format flag 不是惰性的(对照附录 A.3:
|
||||
// 历史上 --format 六值 stdout 逐字节相同即判 flag 惰性缺陷)。
|
||||
env := NewSuccessEnvelope(map[string]any{"path": "/tmp/a.bin", "size": 42})
|
||||
var rawBuf, jsonBuf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
|
||||
}
|
||||
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(json): %v", err)
|
||||
}
|
||||
if bytes.Equal(rawBuf.Bytes(), jsonBuf.Bytes()) {
|
||||
t.Fatalf("-f raw must differ from -f json byte-for-byte (format flag must not be inert):\n%s", rawBuf.String())
|
||||
}
|
||||
// raw = 数据载荷透传:恰为 writeRaw(data) 的输出(紧凑单行 JSON +
|
||||
// 终端净化),不含信封外壳键。
|
||||
var direct bytes.Buffer
|
||||
if err := Write(&direct, FormatRaw, env.Data); err != nil {
|
||||
t.Fatalf("Write(raw, data): %v", err)
|
||||
}
|
||||
if !bytes.Equal(rawBuf.Bytes(), direct.Bytes()) {
|
||||
t.Fatalf("-f raw output must be a verbatim pass-through of the data payload:\n got: %s\nwant: %s", rawBuf.String(), direct.String())
|
||||
}
|
||||
if strings.Contains(rawBuf.String(), `"outcome"`) || strings.Contains(rawBuf.String(), `"ok"`) {
|
||||
t.Fatalf("-f raw must not emit the envelope wrapper: %s", rawBuf.String())
|
||||
}
|
||||
// 数据值可用:raw 输出可解码回业务载荷。
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rawBuf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("raw output is not decodable JSON for structured payload: %v\n%s", err, rawBuf.String())
|
||||
}
|
||||
if decoded["path"] != "/tmp/a.bin" {
|
||||
t.Fatalf("raw payload lost data: %s", rawBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixFormatDispatchMatrixOnEnvelopePath(t *testing.T) {
|
||||
// 六格式逐格式断言分发行为(§5.2 矩阵在信封出口的行为端):
|
||||
// json = 完整信封;table/pretty = 数据 + 摘要;csv/ndjson = 裸记录;
|
||||
// raw = 数据透传。全部格式 stdout 非空、均不产出「半个信封」。
|
||||
cases := []struct {
|
||||
format Format
|
||||
wantJSON bool // stdout 是否恰为 JSON 文档(完整信封)
|
||||
wantWrapper bool // 是否含信封外壳键(仅 json 允许)
|
||||
}{
|
||||
{FormatJSON, true, true},
|
||||
{FormatTable, false, false},
|
||||
{FormatPretty, false, false},
|
||||
{FormatCSV, false, false},
|
||||
{FormatNDJSON, false, false},
|
||||
{FormatRaw, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.format), func(t *testing.T) {
|
||||
forceNoColor(t)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), c.format, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo(%s): %v", c.format, err)
|
||||
}
|
||||
out := buf.String()
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("format %s produced empty stdout", c.format)
|
||||
}
|
||||
hasWrapper := strings.Contains(out, `"outcome"`)
|
||||
if hasWrapper != c.wantWrapper {
|
||||
t.Fatalf("format %s wrapper presence = %v, want %v:\n%s", c.format, hasWrapper, c.wantWrapper, out)
|
||||
}
|
||||
if c.wantJSON {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("format %s must emit one complete JSON envelope: %v\n%s", c.format, err, out)
|
||||
}
|
||||
if decoded["ok"] != true {
|
||||
t.Fatalf("format %s envelope ok = %v, want true", c.format, decoded["ok"])
|
||||
}
|
||||
} else {
|
||||
// 数据可见性:三格式与 raw 都能见到业务值。
|
||||
if !strings.Contains(out, "alpha") {
|
||||
t.Fatalf("format %s lost data payload:\n%s", c.format, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestEnvelope() *Envelope {
|
||||
return &Envelope{
|
||||
OK: true,
|
||||
Outcome: OutcomeSuccess,
|
||||
Data: map[string]any{"name": "DemoApp"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeTo_JSONFullEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded["outcome"] != "success" {
|
||||
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||
}
|
||||
// ok 必须序列化为 JSON 布尔(禁止字符串布尔)。
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||
}
|
||||
if _, hasData := decoded["data"]; !hasData {
|
||||
t.Fatalf("data key missing: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeTo_OmitEmptyNoNull(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := &Envelope{OK: true, Outcome: OutcomeSuccess}
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
s := buf.String()
|
||||
for _, key := range []string{`"meta"`, `"error"`, `"identity"`, `"dry_run"`, `"_notice"`} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("empty field %s must be omitted, got %s", key, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "null") {
|
||||
t.Fatalf("envelope must not emit null: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelopeTo_NonJSONRendersDataOnly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatTable, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
s := buf.String()
|
||||
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok"`) {
|
||||
t.Fatalf("table/pretty formats must not emit the envelope wrapper: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "DemoApp") {
|
||||
t.Fatalf("data payload missing from table output: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_CommandUsesCmdStdoutAndFormat(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().StringP("format", "f", "", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
if err := cmd.Flags().Set("format", "table"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if err := WriteEnvelope(cmd, newTestEnvelope(), FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"outcome"`) {
|
||||
t.Fatalf("-f table must render data only, got %s", stdout.String())
|
||||
}
|
||||
|
||||
// 默认 json:完整信封落 stdout。
|
||||
stdout.Reset()
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
cmd2.Flags().StringP("format", "f", "", "")
|
||||
cmd2.SetOut(&stdout)
|
||||
cmd2.SetErr(&stderr)
|
||||
if err := WriteEnvelope(cmd2, newTestEnvelope(), FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decoded["outcome"] != "success" {
|
||||
t.Fatalf("json envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_NilEnvelopeDoesNotPanic(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "t"}
|
||||
cmd.Flags().StringP("format", "f", "json", "")
|
||||
cmd.SetOut(&buf)
|
||||
if err := WriteEnvelope(cmd, nil, FormatJSON); err != nil {
|
||||
t.Fatalf("WriteEnvelope: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded["outcome"] != "failure" {
|
||||
t.Fatalf("nil envelope should serialize as failure: %s", buf.String())
|
||||
}
|
||||
// B208(轮6裁决⑨):兜底信封必须是 I3 合法形态——ok:false/outcome:failure
|
||||
// 保留,error 明细存在且兜底信封自身通过 Validate()。
|
||||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||
t.Fatalf("nil fallback ok = %v (%T), want false (bool): %s", decoded["ok"], decoded["ok"], buf.String())
|
||||
}
|
||||
errObj, hasErr := decoded["error"].(map[string]any)
|
||||
if !hasErr {
|
||||
t.Fatalf("nil fallback must carry error detail (I3): %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "internal" {
|
||||
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], buf.String())
|
||||
}
|
||||
if msg, _ := errObj["message"].(string); msg != "nil envelope: framework fallback" {
|
||||
t.Fatalf("fallback error.message = %v, want %q: %s", errObj["message"], "nil envelope: framework fallback", buf.String())
|
||||
}
|
||||
if err := nilFallbackEnvelope().Validate(); err != nil {
|
||||
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_PaginationWireKeys(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := newTestEnvelope()
|
||||
env.Data = []any{}
|
||||
env.Meta = &Meta{
|
||||
Count: NewCount(0),
|
||||
Pagination: &Pagination{
|
||||
EndpointExhausted: false,
|
||||
NextToken: "cursor_xyz",
|
||||
Pages: 1,
|
||||
Items: 0,
|
||||
},
|
||||
}
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
Pagination struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
|
||||
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, buf.String())
|
||||
}
|
||||
// 契约 §3:endpoint_exhausted 两态显式——false 必须可解码区分缺席。
|
||||
if decoded.Meta.Pagination.EndpointExhausted {
|
||||
t.Fatalf("endpoint_exhausted = true, want false: %s", buf.String())
|
||||
}
|
||||
if decoded.Meta.Pagination.NextToken != "cursor_xyz" {
|
||||
t.Fatalf("next_token = %q, want cursor_xyz: %s", decoded.Meta.Pagination.NextToken, buf.String())
|
||||
}
|
||||
// wire 层 snake_case 恒等:camelCase 键禁止出现。
|
||||
s := buf.String()
|
||||
for _, key := range []string{`endpointExhausted`, `nextToken`, `nextCommand`, `timedOut`} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("camelCase wire key %s forbidden: %s", key, s)
|
||||
}
|
||||
}
|
||||
// endpoint_exhausted:false 必须显式出现在 wire(无 omitempty)。
|
||||
if !strings.Contains(s, `"endpoint_exhausted": false`) && !strings.Contains(s, `"endpoint_exhausted":false`) {
|
||||
t.Fatalf("endpoint_exhausted must be explicit when false: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Outcome 是一次 CLI 调用的结果类别(互斥,四值)。
|
||||
// 门禁拦截(confirmation_required)是 failure 的子类,不占第五值
|
||||
// (契约规范 §1 结果总图)。
|
||||
type Outcome string
|
||||
|
||||
// 四个规范 Outcome 值。wire 值恒为这些小写字符串(契约规范 §2.5)。
|
||||
const (
|
||||
OutcomeSuccess Outcome = "success"
|
||||
OutcomePending Outcome = "pending"
|
||||
OutcomePartialFailure Outcome = "partial_failure"
|
||||
OutcomeFailure Outcome = "failure"
|
||||
)
|
||||
|
||||
// String 返回 Outcome 的 wire 表示(恒为小写规范值)。
|
||||
func (o Outcome) String() string { return string(o) }
|
||||
|
||||
// MarshalJSON 把 Outcome 序列化为小写规范字符串。仅接受四个规范值,
|
||||
// 非法值(含零值)在序列化期即报错,杜绝静默产出无效 wire 值
|
||||
// (契约规范 §1/§2.5「仅四值」)。
|
||||
func (o Outcome) MarshalJSON() ([]byte, error) {
|
||||
switch o {
|
||||
case OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure:
|
||||
return json.Marshal(string(o))
|
||||
default:
|
||||
return nil, fmt.Errorf("output: cannot marshal invalid outcome %q", string(o))
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOutcome 严格解析 s 为 Outcome:仅接受四个小写规范值,
|
||||
// 拒绝大小写变体、前后空白与空串(契约规范 §1)。
|
||||
func ParseOutcome(s string) (Outcome, error) {
|
||||
switch s {
|
||||
case string(OutcomeSuccess):
|
||||
return OutcomeSuccess, nil
|
||||
case string(OutcomePending):
|
||||
return OutcomePending, nil
|
||||
case string(OutcomePartialFailure):
|
||||
return OutcomePartialFailure, nil
|
||||
case string(OutcomeFailure):
|
||||
return OutcomeFailure, nil
|
||||
}
|
||||
return "", fmt.Errorf("output: invalid outcome %q: must be one of %q, %q, %q, %q",
|
||||
s, OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure)
|
||||
}
|
||||
|
||||
// Envelope 是统一输出信封(契约规范 §2 信封结构)。
|
||||
// 字段声明顺序对齐 §2.5 字段总表;Go 序列化按声明顺序输出,
|
||||
// 由此得到稳定的顶层键顺序(golden 断言的前提)。
|
||||
type Envelope struct {
|
||||
// OK 为框架计算值:ok == (outcome ∈ {success, pending})(不变量 I1)。
|
||||
// 恒序列化(无 omitempty),类型为 bool,禁止字符串布尔。
|
||||
OK bool `json:"ok"`
|
||||
|
||||
// Outcome 仅四值(见 Outcome 常量)。恒序列化。
|
||||
Outcome Outcome `json:"outcome"`
|
||||
|
||||
// Identity 为执行身份(user/app),空则省略(§2.5)。
|
||||
Identity string `json:"identity,omitempty"`
|
||||
|
||||
// DryRun 在 dry-run 预览时为 true,默认省略(§6 场景6)。
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
|
||||
// Data 是业务载荷(L2):强类型 DTO 序列化,禁止手拼 JSON。
|
||||
// 可为 object 或 array;空数组 [] 是合法载荷(原样序列化,
|
||||
// 见空结果表达 data:[] + count:0)。
|
||||
Data any `json:"data,omitempty"`
|
||||
|
||||
// Meta 是元数据层(count/operation/pagination),整体 omitempty。
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
|
||||
// Error 是失败明细(L1):非空 ⇔ outcome==failure(不变量 I3)。
|
||||
Error *ErrorInfo `json:"error,omitempty"`
|
||||
|
||||
// Notice 是系统级通知注入位(_notice,如升级提示),空则省略。
|
||||
// 契约仅规定 object 注入位;具体通知 shape 由后续批次定型。
|
||||
Notice any `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
// Meta 是信封元数据层(契约规范 §2.5)。
|
||||
// 分页/异步操作元数据挂 meta 层而非 data 层(§3)。
|
||||
type Meta struct {
|
||||
// Count 是列表结果条数。指针类型区分「未设置」与「0 条」:
|
||||
// 空结果场景必须显式输出 count:0(§3 / AC-06),
|
||||
// nil 时字段缺席(omitempty)。
|
||||
Count *int `json:"count,omitempty"`
|
||||
|
||||
// Operation 是异步操作信息(pending 信封,§2.2)。
|
||||
Operation *OperationInfo `json:"operation,omitempty"`
|
||||
|
||||
// Pagination 是分页元数据(§3)。
|
||||
Pagination *Pagination `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
// NewCount 返回 n 的指针,供 Meta.Count 使用,
|
||||
// 以区分「count:0」(空结果,必须输出)与「count 缺席」(非列表场景)。
|
||||
func NewCount(n int) *int { return &n }
|
||||
|
||||
// OperationInfo 是异步操作元数据(契约规范 §2.2)。
|
||||
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
|
||||
// NextCommand 必须是可直接执行的完整命令(恢复方式)。
|
||||
type OperationInfo struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
// Pagination 是分页元数据(契约规范 §3)。
|
||||
// EndpointExhausted 仅表示「观察到服务端分页耗尽」,不承诺索引健康或
|
||||
// 数据全覆盖;弃用 complete 键。EndpointExhausted:false 必须携带
|
||||
// NextToken(可续跑)——两者合起来即全部状态,不设 stop_reason。
|
||||
// EndpointExhausted 不加 omitempty:true/false 两态都必须在 wire 上显式出现。
|
||||
type Pagination struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Items int `json:"items,omitempty"`
|
||||
NextToken string `json:"next_token,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorInfo 是失败信封的错误明细(契约规范 §2.4)。
|
||||
// Type/Subtype/Code/Retryable/RetryAfterSeconds 为 wire-stable(Agent 可分支);
|
||||
// Message/Hint/RequestID 为 informational(禁止分支)。
|
||||
type ErrorInfo struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
UpstreamCode any `json:"upstream_code,omitempty"`
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
|
||||
// Retryable 仅为 true 时出现在 wire(§2.4;重试设计 §2.1):
|
||||
// bool 零值 false 遇 omitempty 即字段缺席。CLI 不自动重放。
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
|
||||
// RetryAfterSeconds 是服务端给出的重试等待秒数。指针类型保留
|
||||
// 零值语义:0 秒有意义(nil 缺席、&0 序列化为 0),负值应在
|
||||
// 构造侧拒绝。
|
||||
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
ServerKey string `json:"server_key,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
ExecutionStarted *bool `json:"execution_started,omitempty"`
|
||||
NextRetryAt string `json:"next_retry_at,omitempty"`
|
||||
AvailableFlags []string `json:"available_flags,omitempty"`
|
||||
SnapshotPath string `json:"snapshot_path,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
RPCData any `json:"rpc_data,omitempty"`
|
||||
TechnicalDetail string `json:"technical_detail,omitempty"`
|
||||
FriendlyHint string `json:"friendly_hint,omitempty"`
|
||||
ActionURL string `json:"action_url,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
|
||||
// Param/Params 在校验错误时指明违规参数(§2.4)。
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []string `json:"params,omitempty"`
|
||||
|
||||
// Actions 是可执行的补救命令(门禁拦截 subtype=confirmation_required
|
||||
// 时携带,含 --yes 版本;§2.4)。
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateDataErrorExclusivity 校验 wire 层面的 data/error 互斥(契约规范
|
||||
// §1 不变量 I3 的字段级推论;§2.3/§2.4 形态):error 非空时 data 必须缺席——
|
||||
// 失败信封只承载 error 明细,部分成功明细走 data 且不携带顶层 error。
|
||||
// 无违反返回 nil;聚合校验入口(B18 Envelope.Validate)将组合本检查。
|
||||
func (e *Envelope) ValidateDataErrorExclusivity() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.Error != nil && e.Data != nil {
|
||||
return fmt.Errorf("output: envelope violates invariant I3: data and error are mutually exclusive, data must be absent when error is present")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 是信封的聚合校验入口(B18,契约规范 §1):一次性收集全部违反项
|
||||
// (errors.Join)而非命中即返回,便于装配侧一轮定位所有契约缺陷。校验项:
|
||||
//
|
||||
// - outcome 必须是四个规范值之一(§1/§2.5,复用 ParseOutcome);
|
||||
// - 不变量 I1:OK == (outcome ∈ {success, pending})——框架计算值一致性;
|
||||
// - 不变量 I3:Error 非空 ⇔ outcome == failure(双向);
|
||||
// - I3 字段级推论:data/error 互斥(复用 ValidateDataErrorExclusivity);
|
||||
// - Data 为类型化 *PartialData 时的三通道纪律(对账、非空 succeeded、
|
||||
// id 可标识与跨通道互斥,§2.3;裸 map 等业务载荷形态不做解释)。
|
||||
//
|
||||
// outcome 非法时其派生检查(I1/I3)失去判定基准,仅报告 outcome 本身。
|
||||
// Validate 只做静态字段校验,不解释业务载荷内容(L3 真实性归产品命令,§9)。
|
||||
// nil 信封返回 nil:wire 出口(WriteEnvelopeTo)对 nil 已有降级 failure
|
||||
// 信封的兜底,类型层不重复拦截。
|
||||
func (e *Envelope) Validate() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
outcome, err := ParseOutcome(string(e.Outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
wantOK := outcome == OutcomeSuccess || outcome == OutcomePending
|
||||
if e.OK != wantOK {
|
||||
errs = append(errs, fmt.Errorf("output: envelope violates invariant I1: ok=%v but outcome=%q requires ok=%v", e.OK, outcome, wantOK))
|
||||
}
|
||||
errorPresent := e.Error != nil
|
||||
isFailure := outcome == OutcomeFailure
|
||||
if errorPresent != isFailure {
|
||||
errs = append(errs, fmt.Errorf("output: envelope violates invariant I3: error present=%v but outcome=%q (error must be present if and only if outcome is failure)", errorPresent, outcome))
|
||||
}
|
||||
if err := e.ValidateDataErrorExclusivity(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if pd, ok := e.Data.(*PartialData); ok && pd != nil {
|
||||
if err := pd.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if e.Meta != nil && e.Meta.Operation != nil {
|
||||
if err := e.Meta.Operation.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if e.Meta != nil && e.Meta.Pagination != nil {
|
||||
if err := e.Meta.Pagination.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if e.Error != nil {
|
||||
if err := e.Error.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (p *Pagination) Validate() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.EndpointExhausted && strings.TrimSpace(p.NextToken) != "" {
|
||||
return fmt.Errorf("output: exhausted pagination must not carry next_token")
|
||||
}
|
||||
if !p.EndpointExhausted && strings.TrimSpace(p.NextToken) == "" {
|
||||
return fmt.Errorf("output: non-exhausted pagination requires next_token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *ErrorInfo) Validate() error {
|
||||
if e == nil {
|
||||
return fmt.Errorf("output: failure error is nil")
|
||||
}
|
||||
errorType := strings.TrimSpace(e.Type)
|
||||
if errorType == "" {
|
||||
return fmt.Errorf("output: failure error.type is required")
|
||||
}
|
||||
// error.type is a wire-stable Agent branch key, not an open-ended label.
|
||||
// Keep this set aligned with exitCodeForErrorInfo. "permission" is the
|
||||
// compatibility projection for PAT failures (rc=4).
|
||||
switch errorType {
|
||||
case "api", "auth", "validation", "permission", "discovery", "internal":
|
||||
default:
|
||||
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
|
||||
}
|
||||
if e.ExitCode < 0 {
|
||||
return fmt.Errorf("output: failure error.exit_code must not be negative")
|
||||
}
|
||||
if e.RetryAfterSeconds != nil && *e.RetryAfterSeconds < 0 {
|
||||
return fmt.Errorf("output: failure retry_after_seconds must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSuccessEnvelope 构造成功信封(B19,契约规范 §2.1)。
|
||||
// OK 由框架按 outcome 计算(I1),调用层不得自行改写。data 必须是强类型
|
||||
// DTO 序列化产物,禁止手拼 JSON(§2.5/§7);空数组 [] 是合法载荷(AC-06)。
|
||||
func NewSuccessEnvelope(data any) *Envelope {
|
||||
return &Envelope{OK: true, Outcome: OutcomeSuccess, Data: data}
|
||||
}
|
||||
|
||||
// NewPendingEnvelope 构造异步受理信封(B19,契约规范 §2.2)。
|
||||
// op 携带 id/state/timed_out/next_command;op 为 nil 时 meta 整体缺席。
|
||||
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
|
||||
// NextCommand 必须是可直接执行的完整命令(§2.2 规则)。
|
||||
//
|
||||
// 本构造器保持「构造宽松 + 校验严格」分层(轮6裁决⑨):§2.2 两条硬规则
|
||||
// 不在构造期拦截,由 OperationInfo.Validate 与严格装配助手
|
||||
// NewPendingEnvelopeForPolling(B147/B148)把守。
|
||||
func NewPendingEnvelope(op *OperationInfo) *Envelope {
|
||||
env := &Envelope{OK: true, Outcome: OutcomePending}
|
||||
if op != nil {
|
||||
env.Meta = &Meta{Operation: op}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// NewFailureEnvelope 构造失败信封(B20,契约规范 §2.4)。
|
||||
// error 非空 ⇔ outcome==failure(I3):info 为 nil 的失败信封无法通过
|
||||
// Validate(),调用侧不得提交;data 必须缺席(I3 字段级推论)。
|
||||
func NewFailureEnvelope(info *ErrorInfo) *Envelope {
|
||||
return &Envelope{OK: false, Outcome: OutcomeFailure, Error: info}
|
||||
}
|
||||
|
||||
// NewPartialEnvelope 构造部分成功信封(B20,契约规范 §2.3)。
|
||||
// 三通道明细(total/succeeded/failed/unknown)进 data,不携带顶层 error。
|
||||
// 通道规则(B140/B141/B144 已落盘):推荐以 NewPartialData 装配明细——
|
||||
// 构造期拒绝空 succeeded(全失败禁用 partial_failure);Envelope.Validate
|
||||
// 对 *PartialData 载荷追加通道校验(同 id 跨通道互斥、total 对账等)。
|
||||
func NewPartialEnvelope(data any) *Envelope {
|
||||
return &Envelope{OK: false, Outcome: OutcomePartialFailure, Data: data}
|
||||
}
|
||||
|
||||
// IsOK 计算不变量 I1(B21,契约规范 §1):ok == (outcome ∈ {success, pending})。
|
||||
// 这是框架侧的唯一计算函数,命令层不可写或改写该结果;Envelope.OK 字段值
|
||||
// 必须与 IsOK 一致(Validate 交叉校验,违反即 I1 缺陷)。
|
||||
// nil 信封视为不 ok:没有结果可言,不得汇报为成功/受理。
|
||||
// 非法(非四值)outcome 必然返回 false:I1 只定义在四个规范值上,
|
||||
// 非法值由 ParseOutcome/Validate/MarshalJSON 各自拒绝。
|
||||
func (e *Envelope) IsOK() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
return e.Outcome == OutcomeSuccess || e.Outcome == OutcomePending
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase G:partial_failure 三通道明细(B139~B146;契约规范 §2.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PartialFailedEntry 是 partial 失败通道的单条明细(B139,契约规范 §2.3):
|
||||
// id 标识条目,Error 携带该条目的失败明细(复用 §2.4 ErrorInfo 形态,
|
||||
// wire-stable 字段组可供 Agent 分支)。
|
||||
type PartialFailedEntry struct {
|
||||
ID string `json:"id"`
|
||||
Error *ErrorInfo `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PartialUnknownEntry 是 partial unknown 通道的单条明细(B144,契约规范
|
||||
// §2.3/P0#9「报错却写入」类问题的诚实通道):shape = id + reason——
|
||||
// 已提交但终态无法确认的条目放这里;reason 必须非空,说明不确定的原因。
|
||||
type PartialUnknownEntry struct {
|
||||
ID string `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PartialData 是部分成功三通道明细的类型化形态(B139,契约规范 §2.3),
|
||||
// 整体放入 Envelope.Data。声明顺序 = wire 顺序:total/succeeded/failed/
|
||||
// unknown(对齐契约示例)。三通道纪律(B140/B141/B144/B146)由
|
||||
// NewPartialData 构造期校验 + Validate 集中复验:
|
||||
//
|
||||
// - succeeded 必须完整保留(构造器不接受丢弃明细,§2.3 规则1);
|
||||
// - unknown 条目禁止归入 succeeded/failed(同一 id 跨通道互斥,§2.3 规则2);
|
||||
// - 空 succeeded 禁用 partial_failure(全失败必须用普通 failure,§2.3 规则4)。
|
||||
//
|
||||
// 三个切片字段不带 omitempty:空通道以 [] 显式表达(不输出 null,
|
||||
// §2.5 无 null 纪律),与 data:[] 空态合法载荷同口径。
|
||||
type PartialData struct {
|
||||
Total int `json:"total"`
|
||||
Succeeded []any `json:"succeeded"`
|
||||
Failed []PartialFailedEntry `json:"failed"`
|
||||
Unknown []PartialUnknownEntry `json:"unknown"`
|
||||
}
|
||||
|
||||
// NewPartialData 装配三通道明细(B139/B140/B141/B144/B146,契约规范 §2.3)。
|
||||
// 装配期即校验通道纪律:
|
||||
//
|
||||
// 1. succeeded 非空且原样保留——空 succeeded 的 partial_failure 被拒绝
|
||||
// (全部失败必须用 outcome:failure,§2.3 规则4;B141);构造器不丢弃、
|
||||
// 不过滤任何 succeeded 条目(B146:Agent 需要知道哪些已生效,避免重复提交);
|
||||
// 2. failed/unknown 条目 id 必须非空(条目可标识是跨通道互斥的唯一依据);
|
||||
// unknown 条目 reason 必须非空(诚实通道必须说明不确定原因,B144);
|
||||
// 3. 同一 id 不得跨通道或同通道重复出现——一个条目只有一个终态通道
|
||||
// (「unknown 禁止归入 succeeded/failed」,§2.3 规则2;B140)。succeeded
|
||||
// 条目是异构业务 DTO,其 id 按 JSON 泛化形态的 "id" 键提取;无 id 或非
|
||||
// object 的条目不参与互斥判定(框架不解释业务载荷内容,§9);
|
||||
// 4. total 对账:total == len(succeeded)+len(failed)+len(unknown)——
|
||||
// 三通道明细是 total 的完整划分,不允许出现无明细的条目。
|
||||
//
|
||||
// nil 的 failed/unknown 入参归一为空切片(空通道 wire 上是 [] 而非 null)。
|
||||
func NewPartialData(total int, succeeded []any, failed []PartialFailedEntry, unknown []PartialUnknownEntry) (*PartialData, error) {
|
||||
if failed == nil {
|
||||
failed = []PartialFailedEntry{}
|
||||
}
|
||||
if unknown == nil {
|
||||
unknown = []PartialUnknownEntry{}
|
||||
}
|
||||
d := &PartialData{Total: total, Succeeded: succeeded, Failed: failed, Unknown: unknown}
|
||||
return d, d.Validate()
|
||||
}
|
||||
|
||||
// Validate 校验三通道纪律(B140)。NewPartialData 构造期已校验;本方法供
|
||||
// 手工装配形态(绕过构造器)集中复验,并由 Envelope.Validate 在
|
||||
// outcome==partial_failure 且 Data 为 *PartialData 时聚合调用。
|
||||
func (d *PartialData) Validate() error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if d.Succeeded == nil || d.Failed == nil || d.Unknown == nil {
|
||||
errs = append(errs, fmt.Errorf("output: partial channels must serialize as [] not null (§2.5 no-null discipline)"))
|
||||
}
|
||||
// B141:全失败禁用 partial_failure(§2.3 规则4)。
|
||||
if len(d.Succeeded) == 0 {
|
||||
errs = append(errs, fmt.Errorf("output: partial_failure with empty succeeded is forbidden: all-failed batches must use outcome:failure (§2.3)"))
|
||||
}
|
||||
// B146:succeeded 完整保留——不接受 nil(null)条目混入明细。
|
||||
for i, item := range d.Succeeded {
|
||||
if item == nil {
|
||||
errs = append(errs, fmt.Errorf("output: partial succeeded[%d] is nil: succeeded details must be fully preserved entries (§2.3)", i))
|
||||
}
|
||||
}
|
||||
for i, entry := range d.Failed {
|
||||
if strings.TrimSpace(entry.ID) == "" {
|
||||
errs = append(errs, fmt.Errorf("output: partial failed[%d] has empty id: entries must be identifiable (§2.3)", i))
|
||||
}
|
||||
if entry.Error == nil {
|
||||
errs = append(errs, fmt.Errorf("output: partial failed[%d] requires a typed error (§2.3)", i))
|
||||
} else if err := entry.Error.Validate(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("output: partial failed[%d] has invalid typed error: %w", i, err))
|
||||
}
|
||||
}
|
||||
// B144:unknown shape = id + reason,两者都必须非空。
|
||||
for i, entry := range d.Unknown {
|
||||
if strings.TrimSpace(entry.ID) == "" {
|
||||
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty id: entries must be identifiable (§2.3)", i))
|
||||
}
|
||||
if strings.TrimSpace(entry.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty reason: the honest channel must state why the terminal state is unconfirmed (§2.3/P0#9)", i))
|
||||
}
|
||||
}
|
||||
// B141/§2.3:total 对账——三通道明细是 total 的完整划分。
|
||||
if sum := len(d.Succeeded) + len(d.Failed) + len(d.Unknown); d.Total != sum {
|
||||
errs = append(errs, fmt.Errorf("output: partial total=%d does not reconcile with channel details (succeeded %d + failed %d + unknown %d = %d) (§2.3)",
|
||||
d.Total, len(d.Succeeded), len(d.Failed), len(d.Unknown), sum))
|
||||
}
|
||||
// B140:同一 id 跨通道/同通道互斥(unknown 不得归入 succeeded/failed)。
|
||||
seen := make(map[string]string)
|
||||
register := func(id, channel string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if prev, dup := seen[id]; dup {
|
||||
errs = append(errs, fmt.Errorf("output: partial entry id %q appears in both %s and %s: one entry has exactly one terminal channel (§2.3)", id, prev, channel))
|
||||
return
|
||||
}
|
||||
seen[id] = channel
|
||||
}
|
||||
for _, item := range d.Succeeded {
|
||||
register(partialEntryID(item), "succeeded")
|
||||
}
|
||||
for _, entry := range d.Failed {
|
||||
register(entry.ID, "failed")
|
||||
}
|
||||
for _, entry := range d.Unknown {
|
||||
register(entry.ID, "unknown")
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// partialEntryID 从异构 succeeded 条目中提取 "id"(JSON 泛化形态的字符串键)。
|
||||
// 无 id、id 非字符串或非 object 的条目返回空串(不参与互斥判定)。
|
||||
func partialEntryID(item any) string {
|
||||
generic, ok := toGeneric(item).(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
id, _ := generic["id"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase H:operation / pagination 装配助手(B147~B152;契约规范 §2.2/§3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// operation.state 取值表(B150,契约规范 §2.2):state 描述异步操作所处阶段,
|
||||
// 规范值为以下集合(开放枚举——业务特化阶段容忍,但以下三值是 Agent 必须能
|
||||
// 分支的最小集)。timed_out:true 禁止与完成态共存(§2.2 规则1,见
|
||||
// OperationInfo.ValidateTimeoutState)。
|
||||
const (
|
||||
OperationStateProcessing = "processing"
|
||||
OperationStateCompleted = "completed"
|
||||
OperationStateFailed = "failed"
|
||||
)
|
||||
|
||||
// ValidateNextCommand 校验 next_command 的可执行形态(B148,契约规范 §2.2
|
||||
// 规则2「next_command 必须是可直接执行的完整命令」):非空时必须是无前后
|
||||
// 空白、不含换行的单行命令串——这是框架侧对「可直接执行」的形态代理;
|
||||
// 命令真实可执行性(命令存在、参数有效)归装配侧保证(§9)。
|
||||
// 空串在此不拦截:omitempty wire 允许无 operation 明细的裸 pending 信封,
|
||||
// 轮询形态装配 NewPendingEnvelopeForPolling 才要求非空。
|
||||
func (op *OperationInfo) ValidateNextCommand() error {
|
||||
if op == nil {
|
||||
return nil
|
||||
}
|
||||
if op.NextCommand == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(op.NextCommand) != op.NextCommand {
|
||||
return fmt.Errorf("output: operation.next_command %q must carry no leading/trailing whitespace (§2.2: directly executable)", op.NextCommand)
|
||||
}
|
||||
if strings.ContainsAny(op.NextCommand, "\n\r") {
|
||||
return fmt.Errorf("output: operation.next_command must be a single executable command line (§2.2)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimeoutState 执行 §2.2 规则1 的防伪装校验(B149):轮询超时必须
|
||||
// 保持 state 真实值并置 timed_out:true——TimedOut 时 State 必须非空(真实值
|
||||
// 必报),且不得宣称任何完成态(completed/success):超时操作宣称完成态
|
||||
// 即伪装 success(对齐 lark wiki_node_delete 教训)。
|
||||
func (op *OperationInfo) ValidateTimeoutState() error {
|
||||
if op == nil || !op.TimedOut {
|
||||
return nil
|
||||
}
|
||||
state := strings.TrimSpace(op.State)
|
||||
if state == "" {
|
||||
return fmt.Errorf("output: operation.timed_out=true requires the real state to be reported (empty state, §2.2 anti-spoof)")
|
||||
}
|
||||
if state == OperationStateCompleted || strings.EqualFold(state, string(OutcomeSuccess)) {
|
||||
return fmt.Errorf("output: operation.timed_out=true must keep the real state %q: a timed-out operation must not claim a completed state (§2.2 anti-spoof)", state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 聚合 operation 的 §2.2 形态校验。必填字段由新框架的
|
||||
// CommandResult 边界校验;这里保持旧 Envelope 构造 API 的兼容性。
|
||||
func (op *OperationInfo) Validate() error {
|
||||
if op == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(op.ValidateNextCommand(), op.ValidateTimeoutState())
|
||||
}
|
||||
|
||||
// NewPendingEnvelopeForPolling 装配轮询形态的 pending 信封(B147 增强,
|
||||
// 契约规范 §2.2):比 NewPendingEnvelope 更严格——轮询形态的恢复方式只有
|
||||
// next_command 一条出口,故 op 与 next_command 必须非空且通过可执行形态
|
||||
// 校验;state 必须遵守 timed_out 防伪装规则。返回 I1 一致(ok:true/
|
||||
// outcome:pending)的信封;装配失败返回 error(信封为 nil)。
|
||||
func NewPendingEnvelopeForPolling(op *OperationInfo) (*Envelope, error) {
|
||||
if op == nil {
|
||||
return nil, fmt.Errorf("output: polling-form pending envelope requires operation (§2.2: meta.operation is the recovery carrier)")
|
||||
}
|
||||
if strings.TrimSpace(op.NextCommand) == "" {
|
||||
return nil, fmt.Errorf("output: polling-form pending envelope requires a non-empty, directly executable next_command (§2.2 rule 2)")
|
||||
}
|
||||
if err := op.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewPendingEnvelope(op), nil
|
||||
}
|
||||
|
||||
// NewPagination 装配分页元数据(B152,契约规范 §3):两态完备校验——
|
||||
// endpoint_exhausted:false 必须携带 next_token(可续跑:无 token 则
|
||||
// 「可续跑」不可表达,破坏 §3 两态完备性);endpoint_exhausted:true
|
||||
// 不得携带 next_token(耗尽即无续跑凭据,两者合起来即全部状态,
|
||||
// 不设 stop_reason)。Pages/Items 为信息性计数(omitempty),由调用方
|
||||
// 按观察到的页数/条数在返回结构上另行设置。
|
||||
func NewPagination(endpointExhausted bool, nextToken string) (*Pagination, error) {
|
||||
token := strings.TrimSpace(nextToken)
|
||||
if !endpointExhausted && token == "" {
|
||||
return nil, fmt.Errorf("output: pagination endpoint_exhausted=false requires next_token (§3: resumable state must carry the resume handle)")
|
||||
}
|
||||
if endpointExhausted && token != "" {
|
||||
return nil, fmt.Errorf("output: pagination endpoint_exhausted=true must not carry next_token (§3: exhausted and resumable are the only two states)")
|
||||
}
|
||||
pg := &Pagination{EndpointExhausted: endpointExhausted, NextToken: token}
|
||||
return pg, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
// Phase I:信封渲染字节稳定性(B188;AC-07)。落盘策略:轮8裁决⑩新文件。
|
||||
//
|
||||
// 契约卫生的可复现前提:同一信封输入,多次渲染必须逐字节一致。Go 的 map
|
||||
// 迭代顺序随机、encoding/json 对 map 按键排序序列化——本测试用大 map 载荷
|
||||
// 反复渲染,锁死「字节稳定」这一性质:任何引入非确定性渲染(如依赖 map
|
||||
// 迭代序、时间戳、指针地址、随机序)的改动都会让多次渲染产生字节漂移,
|
||||
// 从而命中断言。golden 比对(B157)锁的是「与基准一致」,本测试锁的是
|
||||
// 「自身多次渲染一致」,两者互补。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// buildLargeMapPayload 构造一个多键 map 载荷:键数足够多,使 map 迭代顺序
|
||||
// 随机性的影响面充分暴露(小 map 可能偶然同序,掩盖非确定性)。
|
||||
func buildLargeMapPayload(n int) map[string]any {
|
||||
m := make(map[string]any, n)
|
||||
for i := 0; i < n; i++ {
|
||||
m[fmt.Sprintf("key_%03d", i)] = map[string]any{
|
||||
"id": fmt.Sprintf("id_%03d", i),
|
||||
"name": fmt.Sprintf("name_%03d", i),
|
||||
"weight": i,
|
||||
"active": i%2 == 0,
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// renderEnvelopeBytes 用生产路径同一序列化函数渲染信封(两空格缩进)。
|
||||
func renderEnvelopeBytes(t *testing.T, env *Envelope) []byte {
|
||||
t.Helper()
|
||||
data, err := jsonutil.MarshalIndent(env, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestEnvelopeRenderByteStabilityAcrossRepeatedRenders 是 B188 的核心断言:
|
||||
// 同一信封反复渲染 N 次,每次输出逐字节相同(防 map 序抖动)。
|
||||
func TestEnvelopeRenderByteStabilityAcrossRepeatedRenders(t *testing.T) {
|
||||
// 载荷用大 map,放大迭代序随机性;envelope 字段声明序是确定的,
|
||||
// 但 data 内的 map 若被非确定性渲染即会漂移。
|
||||
env := NewSuccessEnvelope(buildLargeMapPayload(64))
|
||||
env.Meta = &Meta{Count: NewCount(64)}
|
||||
|
||||
first := renderEnvelopeBytes(t, env)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("render produced empty output")
|
||||
}
|
||||
const iterations = 50
|
||||
for i := 1; i < iterations; i++ {
|
||||
got := renderEnvelopeBytes(t, env)
|
||||
if !bytes.Equal(got, first) {
|
||||
t.Fatalf("render #%d drifted byte-for-byte from render #0 (map-order jitter / nondeterminism): first diff at byte %d",
|
||||
i, firstDiffIndex(first, got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstDiffIndex 返回两个字节切片首个不同位置的索引(全等返回 -1),
|
||||
// 便于定位漂移起点。
|
||||
func firstDiffIndex(a, b []byte) int {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if a[i] != b[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes 把字节稳定性断言扩展到
|
||||
// 四类 outcome(含携带 map 载荷的 pending/partial),确保各形态渲染均稳定。
|
||||
func TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes(t *testing.T) {
|
||||
envs := map[string]*Envelope{
|
||||
"success": NewSuccessEnvelope(buildLargeMapPayload(40)),
|
||||
"pending": newStabilityPendingFixture(),
|
||||
"partial_failure": newStabilityPartialFixture(t),
|
||||
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018, Message: "rate limited"}),
|
||||
}
|
||||
for name, env := range envs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
first := renderEnvelopeBytes(t, env)
|
||||
for i := 1; i < 20; i++ {
|
||||
got := renderEnvelopeBytes(t, env)
|
||||
if !bytes.Equal(got, first) {
|
||||
t.Fatalf("%s render #%d drifted (nondeterminism): first diff at byte %d",
|
||||
name, i, firstDiffIndex(first, got))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newStabilityPendingFixture() *Envelope {
|
||||
env := NewPendingEnvelope(&OperationInfo{ID: "t_stab", State: OperationStateProcessing, NextCommand: "dws op get t_stab"})
|
||||
env.Data = buildLargeMapPayload(40)
|
||||
return env
|
||||
}
|
||||
|
||||
func newStabilityPartialFixture(t *testing.T) *Envelope {
|
||||
t.Helper()
|
||||
succeeded := make([]any, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
succeeded = append(succeeded, map[string]any{
|
||||
"id": fmt.Sprintf("s_%03d", i),
|
||||
"detail": buildLargeMapPayload(8), // 每条 succeeded 内嵌多键 map
|
||||
"message": fmt.Sprintf("msg_%03d", i),
|
||||
})
|
||||
}
|
||||
pd, err := NewPartialData(len(succeeded)+1,
|
||||
succeeded,
|
||||
[]PartialFailedEntry{{ID: "f_001", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPartialData: %v", err)
|
||||
}
|
||||
return NewPartialEnvelope(pd)
|
||||
}
|
||||
|
||||
// TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder 验证字节稳定
|
||||
// 不依赖构造顺序:两个字段插入顺序不同但内容等价的 map,渲染结果逐字节一致
|
||||
// (encoding/json 对 map 按键排序,故构造序不影响 wire——锁死这一性质)。
|
||||
func TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder(t *testing.T) {
|
||||
// 正向插入。
|
||||
a := map[string]any{}
|
||||
for _, k := range []string{"alpha", "beta", "gamma", "delta"} {
|
||||
a[k] = k + "_value"
|
||||
}
|
||||
// 逆向插入(同一键集,不同插入序)。
|
||||
b := map[string]any{}
|
||||
for _, k := range []string{"delta", "gamma", "beta", "alpha"} {
|
||||
b[k] = k + "_value"
|
||||
}
|
||||
gotA := renderEnvelopeBytes(t, NewSuccessEnvelope(a))
|
||||
gotB := renderEnvelopeBytes(t, NewSuccessEnvelope(b))
|
||||
if !bytes.Equal(gotA, gotB) {
|
||||
t.Fatalf("construction order leaked into wire bytes (map keys must be sorted on marshal):\n--- a ---\n%s\n--- b ---\n%s", gotA, gotB)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- B21:IsOK() I1 框架计算函数 ---
|
||||
|
||||
func TestEnvelopeIsOKComputesFromOutcome(t *testing.T) {
|
||||
// IsOK 只看 outcome,不看(可能被命令层篡改的)OK 字段——
|
||||
// 「由框架计算,命令层不可写」(契约规范 §1 I1)。
|
||||
env := &Envelope{OK: true, Outcome: OutcomeFailure}
|
||||
if env.IsOK() {
|
||||
t.Fatalf("IsOK must ignore the tampered OK field: outcome=failure requires false")
|
||||
}
|
||||
env2 := &Envelope{OK: false, Outcome: OutcomeSuccess}
|
||||
if !env2.IsOK() {
|
||||
t.Fatalf("IsOK must compute from outcome: success requires true regardless of OK field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeIsOKNilAndInvalidOutcome(t *testing.T) {
|
||||
var env *Envelope
|
||||
if env.IsOK() {
|
||||
t.Fatalf("nil envelope must not be ok: no outcome means no success")
|
||||
}
|
||||
for _, bad := range []Outcome{"", "SUCCESS", "Failure", "ok", "partial"} {
|
||||
if (&Envelope{Outcome: bad}).IsOK() {
|
||||
t.Fatalf("IsOK(%q) = true, want false: I1 is defined only over the four canonical outcomes", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B22:I1 全四值穷举 ---
|
||||
|
||||
func TestInvariantI1ExhaustiveOverFourOutcomes(t *testing.T) {
|
||||
// I1:ok == (outcome ∈ {success, pending}),四值全穷举。
|
||||
cases := []struct {
|
||||
outcome Outcome
|
||||
wantOK bool
|
||||
}{
|
||||
{OutcomeSuccess, true},
|
||||
{OutcomePending, true},
|
||||
{OutcomePartialFailure, false},
|
||||
{OutcomeFailure, false},
|
||||
}
|
||||
if len(cases) != 4 {
|
||||
t.Fatalf("I1 exhaustive table must cover exactly the four canonical outcomes, got %d", len(cases))
|
||||
}
|
||||
for _, c := range cases {
|
||||
// failure 形态附 error 以同时满足 I3(error 非空 ⇔ outcome==failure),
|
||||
// 使 Validate() 对 I1 一致信封整体放行。
|
||||
var errPtr *ErrorInfo
|
||||
if c.outcome == OutcomeFailure {
|
||||
errPtr = &ErrorInfo{Type: "api"}
|
||||
}
|
||||
env := &Envelope{OK: c.wantOK, Outcome: c.outcome, Error: errPtr}
|
||||
if got := env.IsOK(); got != c.wantOK {
|
||||
t.Fatalf("IsOK() for outcome=%q = %v, want %v (I1)", c.outcome, got, c.wantOK)
|
||||
}
|
||||
if env.OK != env.IsOK() {
|
||||
t.Fatalf("envelope ok=%v disagrees with framework-computed IsOK=%v for outcome=%q (I1)",
|
||||
env.OK, env.IsOK(), c.outcome)
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("Validate() on I1-consistent outcome=%q envelope = %v, want nil", c.outcome, err)
|
||||
}
|
||||
// 反向:人为翻转 OK 字段 → Validate 必须报 I1 违反。
|
||||
tampered := &Envelope{OK: !c.wantOK, Outcome: c.outcome}
|
||||
if tampered.IsOK() != c.wantOK {
|
||||
t.Fatalf("IsOK must stay outcome-driven when OK field is tampered (outcome=%q)", c.outcome)
|
||||
}
|
||||
if err := tampered.Validate(); err == nil || !strings.Contains(err.Error(), "I1") {
|
||||
t.Fatalf("Validate() on tampered outcome=%q envelope must report I1 violation, got %v", c.outcome, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariantI1ConstructorAgreement(t *testing.T) {
|
||||
// 四个构造器产出的信封,其 OK 字段与 IsOK() 恒一致(I1 的装配端闭环)。
|
||||
envs := map[string]*Envelope{
|
||||
"success": NewSuccessEnvelope(nil),
|
||||
"pending": NewPendingEnvelope(nil),
|
||||
"partial_failure": NewPartialEnvelope(nil),
|
||||
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api"}),
|
||||
}
|
||||
for name, env := range envs {
|
||||
if env.OK != env.IsOK() {
|
||||
t.Fatalf("constructor %q: OK=%v but IsOK()=%v (I1)", name, env.OK, env.IsOK())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B23:I3 双向穷举(error 非空 ⇔ outcome==failure)---
|
||||
|
||||
func TestInvariantI3BidirectionalExhaustive(t *testing.T) {
|
||||
// 双向穷举:4 个 outcome × {error 缺席, error 非空} = 8 组合,
|
||||
// 仅 outcome==failure ⇔ error 非空 的 2 组合合法(对角线两侧各一)。
|
||||
errInfo := &ErrorInfo{Type: "api", Code: 90018, Message: "too many requests"}
|
||||
for _, outcome := range canonicalOutcomes {
|
||||
for _, errPtr := range []*ErrorInfo{nil, errInfo} {
|
||||
env := &Envelope{
|
||||
OK: outcome == OutcomeSuccess || outcome == OutcomePending,
|
||||
Outcome: outcome,
|
||||
Error: errPtr,
|
||||
}
|
||||
errorPresent := env.Error != nil
|
||||
isFailure := env.Outcome == OutcomeFailure
|
||||
wantValid := errorPresent == isFailure
|
||||
err := env.Validate()
|
||||
if wantValid && err != nil {
|
||||
t.Fatalf("outcome=%q errorPresent=%v must satisfy I3, Validate()=%v", outcome, errorPresent, err)
|
||||
}
|
||||
if !wantValid {
|
||||
if err == nil {
|
||||
t.Fatalf("outcome=%q errorPresent=%v violates I3 but Validate() passed", outcome, errorPresent)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "I3") {
|
||||
t.Fatalf("I3 violation must be reported as such, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariantI3WireDirectionFromConstructors(t *testing.T) {
|
||||
// wire 方向断言:四类构造器产出的信封中,error 字段出现当且仅当
|
||||
// outcome==failure(「⇔」的两个方向都要显式断言)。
|
||||
envs := []*Envelope{
|
||||
NewSuccessEnvelope(map[string]any{"id": "x"}),
|
||||
NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}),
|
||||
NewPartialEnvelope(map[string]any{"total": 1}),
|
||||
NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad"}),
|
||||
}
|
||||
for _, env := range envs {
|
||||
errorPresent := env.Error != nil
|
||||
isFailure := env.Outcome == OutcomeFailure
|
||||
if errorPresent != isFailure {
|
||||
t.Fatalf("constructor envelope outcome=%q: error present=%v, want == isFailure(%v) (I3)",
|
||||
env.Outcome, errorPresent, isFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
// Phase G:partial_failure / unknown 通道(B139~B146;契约规范 §2.3)。
|
||||
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
|
||||
// B143(partial 信封 → exit 7 出口映射)已由轮9 B34 覆盖
|
||||
// (emitter_phase_b_test.go:TestInvariantI2ExhaustiveOverFourOutcomes 的
|
||||
// partial==7 精确断言 + TestEmitterStreamRoutingConsistentWithExitCode 的
|
||||
// stdout 通道 + exit 7 联动断言),本文件不重复造测试。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- B139:PartialData 三通道明细类型(total/succeeded[]/failed[]/unknown[])---
|
||||
|
||||
func TestPartialDataWireShapeAndKeyOrder(t *testing.T) {
|
||||
// wire 形态对齐契约 §2.3 示例:顶层键序 total/succeeded/failed/unknown
|
||||
// (声明序 = wire 序),空通道显式 [] 而非 null(§2.5 无 null 纪律)。
|
||||
pd, err := NewPartialData(2,
|
||||
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||
nil, // nil failed 归一为 []
|
||||
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPartialData: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(pd)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(PartialData): %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("PartialData wire is not valid JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if got, ok := decoded["total"].(float64); !ok || got != 2 {
|
||||
t.Fatalf("total = %v, want 2: %s", decoded["total"], raw)
|
||||
}
|
||||
for _, channel := range []string{"succeeded", "failed", "unknown"} {
|
||||
arr, ok := decoded[channel].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("channel %q must serialize as a JSON array: %s", channel, raw)
|
||||
}
|
||||
if arr == nil {
|
||||
t.Fatalf("channel %q must be [] not null: %s", channel, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(raw), "null") {
|
||||
t.Fatalf("partial channels must never emit null (§2.5): %s", raw)
|
||||
}
|
||||
// 键序断言:json.Decoder 保序读入。
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
|
||||
t.Fatalf("PartialData must be an object: %v (%v)", tok, err)
|
||||
}
|
||||
var keys []string
|
||||
for dec.More() {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
keys = append(keys, tok.(string))
|
||||
var skip json.RawMessage
|
||||
if err := dec.Decode(&skip); err != nil {
|
||||
t.Fatalf("decode value: %v", err)
|
||||
}
|
||||
}
|
||||
if want := []string{"total", "succeeded", "failed", "unknown"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("PartialData key order = %v, want %v (§2.3)", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFailedEntryCarriesErrorInfoShape(t *testing.T) {
|
||||
// failed 通道条目 = id + error(复用 §2.4 ErrorInfo,wire-stable 字段可分支)。
|
||||
entry := PartialFailedEntry{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}
|
||||
raw, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(failed entry): %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("failed entry wire invalid: %v\n%s", err, raw)
|
||||
}
|
||||
if decoded["id"] != "b" {
|
||||
t.Fatalf("failed entry id = %v: %s", decoded["id"], raw)
|
||||
}
|
||||
errObj, ok := decoded["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("failed entry must carry error detail (§2.3): %s", raw)
|
||||
}
|
||||
if errObj["type"] != "api" {
|
||||
t.Fatalf("failed entry error.type = %v: %s", errObj["type"], raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B141:全失败禁用 partial_failure(空 succeeded 拒绝构造)---
|
||||
|
||||
func TestNewPartialDataRejectsEmptySucceeded(t *testing.T) {
|
||||
// §2.3 规则4:全部失败时必须用 outcome:failure,禁止空 succeeded 的
|
||||
// partial_failure。构造期即拒绝。
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
failed []PartialFailedEntry
|
||||
unknown []PartialUnknownEntry
|
||||
}{
|
||||
{"all failed", []PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil},
|
||||
{"all unknown", nil, []PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
|
||||
{"failed and unknown only",
|
||||
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}},
|
||||
[]PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
|
||||
{"empty everything", nil, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
total := len(tc.failed) + len(tc.unknown)
|
||||
pd, err := NewPartialData(total, nil, tc.failed, tc.unknown)
|
||||
if err == nil {
|
||||
t.Fatalf("NewPartialData with empty succeeded must be rejected (§2.3 rule 4), got %+v", pd)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "succeeded") || !strings.Contains(err.Error(), "failure") {
|
||||
t.Fatalf("rejection must name the empty-succeeded/all-failure rule: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 全失败的正确形态:普通 failure 信封(构造合法、Validate 通过)。
|
||||
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Message: "all 3 items failed"})
|
||||
if err := failure.Validate(); err != nil {
|
||||
t.Fatalf("all-failed batch must use a legal failure envelope: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeValidateRejectsHandBuiltAllFailedPartial(t *testing.T) {
|
||||
// 绕过构造器手工装配的全失败 partial 同样被 Validate 拒绝
|
||||
// (校验集中,§2.3 规则4)。
|
||||
pd := &PartialData{
|
||||
Total: 2,
|
||||
Succeeded: []any{},
|
||||
Failed: []PartialFailedEntry{
|
||||
{ID: "a", Error: &ErrorInfo{Type: "api"}},
|
||||
{ID: "b", Error: &ErrorInfo{Type: "api"}},
|
||||
},
|
||||
Unknown: []PartialUnknownEntry{},
|
||||
}
|
||||
env := NewPartialEnvelope(pd)
|
||||
err := env.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate must reject all-failed partial_failure (§2.3 rule 4)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "partial_failure with empty succeeded") {
|
||||
t.Fatalf("error must name the rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialDataTotalReconciliation(t *testing.T) {
|
||||
// total 对账:total != 三通道之和即拒绝(明细是 total 的完整划分)。
|
||||
_, err := NewPartialData(5, []any{map[string]any{"id": "a"}},
|
||||
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("total=5 with 2 detailed entries must be rejected (§2.3 reconciliation)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reconcile") {
|
||||
t.Fatalf("error must name reconciliation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B140:通道校验规则——unknown 条目禁止归入 succeeded/failed ---
|
||||
|
||||
func TestPartialDataRejectsCrossChannelDuplicateIDs(t *testing.T) {
|
||||
succeeded := func(id string) []any { return []any{map[string]any{"id": id, "messageId": "m_1"}} }
|
||||
failed := func(id string) []PartialFailedEntry {
|
||||
return []PartialFailedEntry{{ID: id, Error: &ErrorInfo{Type: "api"}}}
|
||||
}
|
||||
unknown := func(id string) []PartialUnknownEntry {
|
||||
return []PartialUnknownEntry{{ID: id, Reason: "timeout after submit"}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
succeeded []any
|
||||
failed []PartialFailedEntry
|
||||
unknown []PartialUnknownEntry
|
||||
}{
|
||||
{"unknown id also in succeeded", succeeded("c"), nil, unknown("c")},
|
||||
{"unknown id also in failed", nil, failed("c"), unknown("c")},
|
||||
{"failed id also in succeeded", succeeded("b"), failed("b"), nil},
|
||||
{"duplicate within unknown", succeeded("a"), nil,
|
||||
append(unknown("c"), PartialUnknownEntry{ID: "c", Reason: "second report"})},
|
||||
{"duplicate within succeeded", append(succeeded("a"), map[string]any{"id": "a"}), nil, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
total := len(tc.succeeded) + len(tc.failed) + len(tc.unknown)
|
||||
_, err := NewPartialData(total, tc.succeeded, tc.failed, tc.unknown)
|
||||
if err == nil {
|
||||
t.Fatalf("cross/duplicate channel id must be rejected (§2.3 rule 2)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one terminal channel") && !strings.Contains(err.Error(), "exactly one terminal channel") {
|
||||
t.Fatalf("error must name the one-channel-per-entry rule: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialDataAcceptsDistinctChannelIDs(t *testing.T) {
|
||||
// 三通道 id 互不相交时合法(契约 §2.3 标准形态)。
|
||||
pd, err := NewPartialData(3,
|
||||
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("legal three-channel data rejected: %v", err)
|
||||
}
|
||||
env := NewPartialEnvelope(pd)
|
||||
env.Meta = &Meta{Count: NewCount(3)}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("legal partial envelope must pass Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialDataEntriesMustBeIdentifiable(t *testing.T) {
|
||||
// failed/unknown 条目 id 必须非空——条目可标识是跨通道互斥的唯一依据。
|
||||
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||
[]PartialFailedEntry{{Error: &ErrorInfo{Type: "api"}}}, nil); err == nil {
|
||||
t.Fatal("failed entry without id must be rejected (§2.3)")
|
||||
}
|
||||
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||
nil, []PartialUnknownEntry{{Reason: "timeout"}}); err == nil {
|
||||
t.Fatal("unknown entry without id must be rejected (§2.3)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- B144:unknown[] 条目 shape(id + reason)---
|
||||
|
||||
func TestPartialUnknownEntryShapeIDPlusReason(t *testing.T) {
|
||||
// shape 恰为 id + reason 两键(P0#9「报错却写入」类条目的诚实通道)。
|
||||
entry := PartialUnknownEntry{ID: "c", Reason: "timeout after submit"}
|
||||
raw, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(unknown entry): %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unknown entry wire invalid: %v\n%s", err, raw)
|
||||
}
|
||||
if len(decoded) != 2 {
|
||||
t.Fatalf("unknown entry must carry exactly id+reason, got %v: %s", decoded, raw)
|
||||
}
|
||||
if decoded["id"] != "c" || decoded["reason"] != "timeout after submit" {
|
||||
t.Fatalf("unknown entry = %v, want id+reason preserved", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialUnknownEntryRequiresReason(t *testing.T) {
|
||||
// 诚实通道必须说明不确定原因:空 reason 拒绝。
|
||||
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||
nil, []PartialUnknownEntry{{ID: "c"}}); err == nil {
|
||||
t.Fatal("unknown entry without reason must be rejected (§2.3/P0#9)")
|
||||
}
|
||||
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||
nil, []PartialUnknownEntry{{ID: "c", Reason: " "}}); err == nil {
|
||||
t.Fatal("unknown entry with whitespace-only reason must be rejected (§2.3/P0#9)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- B146:succeeded 明细完整保留(构造器不接受丢弃明细)---
|
||||
|
||||
func TestNewPartialDataPreservesSucceededDetailsVerbatim(t *testing.T) {
|
||||
// 构造器不丢弃、不过滤、不重排任何 succeeded 条目(§2.3 规则1:
|
||||
// Agent 需要知道哪些已生效,避免重复提交)。
|
||||
succeeded := []any{
|
||||
map[string]any{"id": "a", "messageId": "m_1"},
|
||||
map[string]any{"id": "d", "messageId": "m_4", "extra": map[string]any{"nested": true}},
|
||||
"d-plain-string-entry", // 异构条目同样原样保留
|
||||
}
|
||||
pd, err := NewPartialData(3, succeeded, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPartialData: %v", err)
|
||||
}
|
||||
if len(pd.Succeeded) != len(succeeded) {
|
||||
t.Fatalf("succeeded length = %d, want %d (no dropping)", len(pd.Succeeded), len(succeeded))
|
||||
}
|
||||
if !reflect.DeepEqual(pd.Succeeded, succeeded) {
|
||||
t.Fatalf("succeeded details altered:\n got %+v\nwant %+v", pd.Succeeded, succeeded)
|
||||
}
|
||||
// wire 层同样完整:异构条目(object 与 string)均可解码回原样。
|
||||
raw, err := json.Marshal(pd)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var generic struct {
|
||||
Succeeded []any `json:"succeeded"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
if len(generic.Succeeded) != 3 {
|
||||
t.Fatalf("wire succeeded length = %d, want 3: %s", len(generic.Succeeded), raw)
|
||||
}
|
||||
first, _ := generic.Succeeded[0].(map[string]any)
|
||||
if first == nil || first["messageId"] != "m_1" {
|
||||
t.Fatalf("wire lost succeeded detail fields: %s", raw)
|
||||
}
|
||||
if generic.Succeeded[2] != "d-plain-string-entry" {
|
||||
t.Fatalf("wire lost heterogeneous succeeded entry: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialDataRejectsNilSucceededEntries(t *testing.T) {
|
||||
// nil(null)条目混入明细即拒绝:完整保留意味着每条都是真实明细。
|
||||
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}, nil}, nil, nil); err == nil {
|
||||
t.Fatal("nil succeeded entry must be rejected (§2.3 full preservation)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- B145:partial 装配样例——2 成 1 败 1 unknown(契约规范 §6 场景3)---
|
||||
|
||||
func TestPartialAssemblyExampleTwoOneOne(t *testing.T) {
|
||||
// 场景3 行为端:ok:false / rc=7 映射 / stderr 空;三通道明细完整落 stdout。
|
||||
pd, err := NewPartialData(4,
|
||||
[]any{
|
||||
map[string]any{"id": "a", "messageId": "m_1"},
|
||||
map[string]any{"id": "b", "messageId": "m_2"},
|
||||
},
|
||||
[]PartialFailedEntry{{ID: "c", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}},
|
||||
[]PartialUnknownEntry{{ID: "d", Reason: "timeout after submit"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPartialData: %v", err)
|
||||
}
|
||||
env := NewPartialEnvelope(pd)
|
||||
env.Meta = &Meta{Count: NewCount(4)}
|
||||
|
||||
// 不变量面:I1 ok==false(partial 非 ok)、I3 无顶层 error、Validate 通过。
|
||||
if env.OK || env.Outcome != OutcomePartialFailure {
|
||||
t.Fatalf("partial envelope = ok:%v outcome:%q, want false/partial_failure", env.OK, env.Outcome)
|
||||
}
|
||||
if env.Error != nil {
|
||||
t.Fatalf("partial envelope must not carry top-level error (I3/§2.3)")
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
|
||||
// 出口面:明细落 stdout、stderr 零输出(§2.3)。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit partial: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||
}
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Data PartialData `json:"data"`
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decoded.OK {
|
||||
t.Fatalf("partial ok = true, want false (§1): %s", stdout.String())
|
||||
}
|
||||
if decoded.Outcome != "partial_failure" {
|
||||
t.Fatalf("outcome = %q: %s", decoded.Outcome, stdout.String())
|
||||
}
|
||||
if decoded.Data.Total != 4 || len(decoded.Data.Succeeded) != 2 ||
|
||||
len(decoded.Data.Failed) != 1 || len(decoded.Data.Unknown) != 1 {
|
||||
t.Fatalf("channel counts = %d/%d/%d/%d, want 4/2/1/1: %s",
|
||||
decoded.Data.Total, len(decoded.Data.Succeeded), len(decoded.Data.Failed), len(decoded.Data.Unknown), stdout.String())
|
||||
}
|
||||
if decoded.Data.Failed[0].Error == nil || decoded.Data.Failed[0].Error.Type != "api" {
|
||||
t.Fatalf("failed entry lost error detail: %s", stdout.String())
|
||||
}
|
||||
if decoded.Data.Unknown[0].Reason != "timeout after submit" {
|
||||
t.Fatalf("unknown entry lost reason: %s", stdout.String())
|
||||
}
|
||||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 4 {
|
||||
t.Fatalf("meta.count = %v, want 4: %s", decoded.Meta.Count, stdout.String())
|
||||
}
|
||||
|
||||
// rc=7 映射(§4 partial 专用码;ExitCodeForEnvelope 与 errors 侧同源见 B209)。
|
||||
if code := ExitCodeForEnvelope(env); code != 7 {
|
||||
t.Fatalf("partial exit code = %d, want dedicated 7 (§4)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B143 覆盖核实说明(不重复造测试)---
|
||||
//
|
||||
// partial 信封 → exit 7 的出口断言已由轮9 B34 落盘并评审通过:
|
||||
// - emitter_phase_b_test.go TestInvariantI2ExhaustiveOverFourOutcomes:
|
||||
// 「partial_failure 专用码精确断言(非零且恰为 7)」;
|
||||
// - emitter_phase_b_test.go TestEmitterStreamRoutingConsistentWithExitCode:
|
||||
// partial_failure 走 stdout 通道且 exit 7 的联动断言。
|
||||
// 本文件 TestPartialAssemblyExampleTwoOneOne 在装配样例内顺带复核 rc=7,
|
||||
// 仅为场景自洽,不构成新的覆盖重复主张。
|
||||
@@ -0,0 +1,421 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package output
|
||||
|
||||
// Phase H:operation / pagination 装配助手(B147~B154;契约规范 §2.2/§3)。
|
||||
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- B150:operation.state 取值表测试 ---
|
||||
|
||||
func TestOperationStateValueTable(t *testing.T) {
|
||||
// state 取值表(§2.2):规范三值 processing/completed/failed 是 Agent
|
||||
// 必须能分支的最小集;开放枚举容忍业务特化阶段(如 importing、queued)。
|
||||
// 表驱动:每个规范值装配合法、wire 原样、Validate 通过。
|
||||
normative := []struct {
|
||||
state string
|
||||
// timed_out 共存合法性由 ValidateTimeoutState 把守,这里只表 state 装配。
|
||||
}{
|
||||
{OperationStateProcessing},
|
||||
{OperationStateCompleted},
|
||||
{OperationStateFailed},
|
||||
}
|
||||
if len(normative) != 3 {
|
||||
t.Fatalf("operation.state normative table must cover exactly 3 values (§2.2), got %d", len(normative))
|
||||
}
|
||||
for _, tc := range normative {
|
||||
t.Run("state="+tc.state, func(t *testing.T) {
|
||||
op := &OperationInfo{ID: "t_1", State: tc.state, NextCommand: "dws op get t_1"}
|
||||
env, err := NewPendingEnvelopeForPolling(op)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPendingEnvelopeForPolling(state=%q): %v", tc.state, err)
|
||||
}
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Meta struct {
|
||||
Operation struct {
|
||||
State string `json:"state"`
|
||||
} `json:"operation"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
if decoded.Meta.Operation.State != tc.state {
|
||||
t.Fatalf("wire state = %q, want %q: %s", decoded.Meta.Operation.State, tc.state, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 开放枚举:业务特化阶段不在规范表内但可装配(框架不解释语义,§9)。
|
||||
op := &OperationInfo{ID: "t_2", State: "importing", NextCommand: "dws op get t_2"}
|
||||
if _, err := NewPendingEnvelopeForPolling(op); err != nil {
|
||||
t.Fatalf("specialized state must remain assemblable (open enum, §2.2): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B148:next_command 非空可执行断言 ---
|
||||
|
||||
func TestPollingPendingEnvelopeRequiresNonEmptyNextCommand(t *testing.T) {
|
||||
// 轮询形态的恢复方式只有 next_command 一条出口(§2.2 规则2):
|
||||
// 非空、无前后空白、单行——缺任一项即装配失败。
|
||||
cases := []struct {
|
||||
name string
|
||||
op *OperationInfo
|
||||
}{
|
||||
{"nil operation", nil},
|
||||
{"empty next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing}},
|
||||
{"whitespace next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " "}},
|
||||
{"leading space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " dws op get t_1"}},
|
||||
{"trailing space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1 "}},
|
||||
{"multi-line", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\ndws op get t_1"}},
|
||||
{"carriage return", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\r"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, err := NewPendingEnvelopeForPolling(tc.op)
|
||||
if err == nil {
|
||||
t.Fatalf("NewPendingEnvelopeForPolling(%+v) must fail (§2.2 rule 2), got %+v", tc.op, env)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 合法形态:可执行单行命令装配通过。
|
||||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||
if err != nil {
|
||||
t.Fatalf("legal next_command rejected: %v", err)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Operation == nil || env.Meta.Operation.NextCommand != "dws op get t_1" {
|
||||
t.Fatalf("assembled envelope lost next_command: %+v", env)
|
||||
}
|
||||
// ValidateNextCommand 对空串不拦截(裸 pending 信封允许无 operation 明细),
|
||||
// 只对非空值做形态校验——严格性由装配助手把守。
|
||||
if err := (&OperationInfo{}).ValidateNextCommand(); err != nil {
|
||||
t.Fatalf("empty next_command must not be rejected by shape validator alone: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B149:timed_out:true 时 state 保留断言(§2.2 防伪装)---
|
||||
|
||||
func TestTimedOutKeepsRealState(t *testing.T) {
|
||||
// §2.2 规则1(lark wiki_node_delete 教训):轮询超时必须保持 state 真实值
|
||||
// 并置 timed_out:true;超时操作不得宣称完成态。
|
||||
cases := []struct {
|
||||
name string
|
||||
op *OperationInfo
|
||||
wantErr bool
|
||||
}{
|
||||
{"timed_out with real processing state",
|
||||
&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||||
{"timed_out with specialized real state",
|
||||
&OperationInfo{ID: "t_1", State: "importing", TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||||
{"timed_out with empty state",
|
||||
&OperationInfo{ID: "t_1", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||
{"timed_out claiming completed",
|
||||
&OperationInfo{ID: "t_1", State: OperationStateCompleted, TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||
{"timed_out claiming success (case-insensitive)",
|
||||
&OperationInfo{ID: "t_1", State: "SUCCESS", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||
{"no timeout keeps completed legal",
|
||||
&OperationInfo{ID: "t_1", State: OperationStateCompleted, NextCommand: "dws op get t_1"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.op.ValidateTimeoutState()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("ValidateTimeoutState(%+v) must reject (§2.2 anti-spoof)", tc.op)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("ValidateTimeoutState(%+v) must accept: %v", tc.op, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// wire 层:timed_out:true 与真实 state 同现。
|
||||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"})
|
||||
if err != nil {
|
||||
t.Fatalf("assembling timed_out envelope: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Meta struct {
|
||||
Operation struct {
|
||||
State string `json:"state"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
} `json:"operation"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
if decoded.Meta.Operation.State != OperationStateProcessing || !decoded.Meta.Operation.TimedOut {
|
||||
t.Fatalf("wire must keep real state + timed_out:true (§2.2), got %+v: %s", decoded.Meta.Operation, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B147:NewPendingEnvelope 装配助手增强 / 轮询形态 ---
|
||||
|
||||
func TestPendingEnvelopeAssemblyAssistants(t *testing.T) {
|
||||
// 宽松构造器(B19)保持构造宽松:无 op / 空 next_command 均可构造。
|
||||
if env := NewPendingEnvelope(nil); env.Meta != nil || !env.OK || env.Outcome != OutcomePending {
|
||||
t.Fatalf("NewPendingEnvelope(nil) = %+v, want bare pending", env)
|
||||
}
|
||||
if env := NewPendingEnvelope(&OperationInfo{ID: "t_1"}); env.Meta == nil || env.Meta.Operation.ID != "t_1" {
|
||||
t.Fatalf("NewPendingEnvelope lost operation: %+v", env)
|
||||
}
|
||||
// 严格装配助手(B147):轮询形态必须携带 op + 可执行 next_command。
|
||||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||||
}
|
||||
// I1 一致性:pending → ok:true。
|
||||
if !env.OK || env.Outcome != OutcomePending {
|
||||
t.Fatalf("polling pending envelope = ok:%v outcome:%q, want true/pending (I1)", env.OK, env.Outcome)
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
// 出口面:pending 落 stdout、exit 0。
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
if stdout.Len() == 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("pending must route stdout (stdout=%d stderr=%d)", stdout.Len(), stderr.Len())
|
||||
}
|
||||
if code := ExitCodeForEnvelope(env); code != 0 {
|
||||
t.Fatalf("pending exit code = %d, want 0 (I2)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B151:import_flow 字段映射断言 ---
|
||||
|
||||
func TestImportFlowOperationFieldMapping(t *testing.T) {
|
||||
// 契约 §2.2 的 import_flow 场景映射:业务侧 task 终态 → operation 字段。
|
||||
// 框架只断言映射后的 wire 形态(§9 语义归装配侧):
|
||||
// task_id → operation.id
|
||||
// status → operation.state
|
||||
// timeout → operation.timed_out(超时时 true,且 state 保留真实值)
|
||||
// 轮询命令 → operation.next_command
|
||||
cases := []struct {
|
||||
name string
|
||||
taskID string
|
||||
status string
|
||||
timedOut bool
|
||||
nextCmd string
|
||||
wantTimed bool
|
||||
wantState string
|
||||
wantNext string
|
||||
}{
|
||||
{"running task", "task_01", "processing", false, "dws op get task_01", false, "processing", "dws op get task_01"},
|
||||
{"timed-out task keeps real state", "task_02", "processing", true, "dws op get task_02", true, "processing", "dws op get task_02"},
|
||||
{"completed task", "task_03", "completed", false, "dws op get task_03", false, "completed", "dws op get task_03"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{
|
||||
ID: tc.taskID,
|
||||
State: tc.status,
|
||||
TimedOut: tc.timedOut,
|
||||
NextCommand: tc.nextCmd,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Meta struct {
|
||||
Operation struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
NextCommand string `json:"next_command"`
|
||||
} `json:"operation"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
op := decoded.Meta.Operation
|
||||
if op.ID != tc.taskID {
|
||||
t.Fatalf("operation.id = %q, want task_id %q: %s", op.ID, tc.taskID, raw)
|
||||
}
|
||||
if op.State != tc.wantState {
|
||||
t.Fatalf("operation.state = %q, want %q: %s", op.State, tc.wantState, raw)
|
||||
}
|
||||
if op.TimedOut != tc.wantTimed {
|
||||
t.Fatalf("operation.timed_out = %v, want %v: %s", op.TimedOut, tc.wantTimed, raw)
|
||||
}
|
||||
if op.NextCommand != tc.wantNext {
|
||||
t.Fatalf("operation.next_command = %q, want %q: %s", op.NextCommand, tc.wantNext, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- B152:NewPagination 装配助手 ---
|
||||
|
||||
func TestNewPaginationTwoStateCompleteness(t *testing.T) {
|
||||
// §3 两态完备:resumable(携带 next_token)与 exhausted(无 token)是
|
||||
// 仅有的两个状态;不设 stop_reason。装配助手强制两态互斥完备。
|
||||
cases := []struct {
|
||||
name string
|
||||
exhausted bool
|
||||
token string
|
||||
wantErr bool
|
||||
}{
|
||||
{"resumable with token", false, "tok_2", false},
|
||||
{"exhausted without token", true, "", false},
|
||||
{"resumable missing token", false, "", true},
|
||||
{"exhausted carrying token", true, "tok_2", true},
|
||||
{"resumable whitespace-only token", false, " ", true},
|
||||
{"exhausted whitespace token accepted as empty", true, " ", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pg, err := NewPagination(tc.exhausted, tc.token)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("NewPagination(exhausted=%v, token=%q) must fail (§3)", tc.exhausted, tc.token)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NewPagination(exhausted=%v, token=%q): %v", tc.exhausted, tc.token, err)
|
||||
}
|
||||
if pg.EndpointExhausted != tc.exhausted {
|
||||
t.Fatalf("endpoint_exhausted = %v, want %v", pg.EndpointExhausted, tc.exhausted)
|
||||
}
|
||||
wantToken := strings.TrimSpace(tc.token)
|
||||
if pg.NextToken != wantToken {
|
||||
t.Fatalf("next_token = %q, want %q", pg.NextToken, wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- B153:Pagination pages/items 字段 ---
|
||||
|
||||
func TestPaginationPagesItemsFields(t *testing.T) {
|
||||
// pages/items 为信息性计数(omitempty):设置后 wire 携带,未设置则缺席。
|
||||
pg, err := NewPagination(false, "tok_2")
|
||||
if err != nil {
|
||||
t.Fatalf("NewPagination: %v", err)
|
||||
}
|
||||
pages, items := 2, 50
|
||||
pg.Pages = pages
|
||||
pg.Items = items
|
||||
raw, err := json.Marshal(pg)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
if got, ok := decoded["pages"].(float64); !ok || got != 2 {
|
||||
t.Fatalf("pages = %v, want 2: %s", decoded["pages"], raw)
|
||||
}
|
||||
if got, ok := decoded["items"].(float64); !ok || got != 50 {
|
||||
t.Fatalf("items = %v, want 50: %s", decoded["items"], raw)
|
||||
}
|
||||
if decoded["next_token"] != "tok_2" || decoded["endpoint_exhausted"] != false {
|
||||
t.Fatalf("pagination wire lost core fields: %s", raw)
|
||||
}
|
||||
// 未设置时 omitempty 缺席。
|
||||
bare, err := NewPagination(true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewPagination(exhausted): %v", err)
|
||||
}
|
||||
raw, err = json.Marshal(bare)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
var bareDecoded map[string]any
|
||||
if err := json.Unmarshal(raw, &bareDecoded); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, raw)
|
||||
}
|
||||
if _, present := bareDecoded["pages"]; present {
|
||||
t.Fatalf("unset pages must be omitted: %s", raw)
|
||||
}
|
||||
if _, present := bareDecoded["items"]; present {
|
||||
t.Fatalf("unset items must be omitted: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B154:空结果 data:[] + count:0 + endpoint_exhausted 断言 ---
|
||||
|
||||
func TestEmptyResultSetWireAssertion(t *testing.T) {
|
||||
// AC-06/§3:空结果合法形态 = data:[] + meta.count:0 +
|
||||
// pagination.endpoint_exhausted:true。三要素齐备且 wire 原样。
|
||||
pg, err := NewPagination(true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewPagination: %v", err)
|
||||
}
|
||||
env := NewSuccessEnvelope([]any{})
|
||||
env.Meta = &Meta{Count: NewCount(0), Pagination: pg}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||
if err := em.Emit(env); err != nil {
|
||||
t.Fatalf("Emit: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Data []any `json:"data"`
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
Pagination struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !decoded.OK || decoded.Outcome != "success" {
|
||||
t.Fatalf("empty result = ok:%v outcome:%q, want true/success", decoded.OK, decoded.Outcome)
|
||||
}
|
||||
if decoded.Data == nil || len(decoded.Data) != 0 {
|
||||
t.Fatalf("data must be [] (not null, not missing): %s", stdout.String())
|
||||
}
|
||||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
|
||||
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, stdout.String())
|
||||
}
|
||||
if !decoded.Meta.Pagination.EndpointExhausted {
|
||||
t.Fatalf("empty result must mark endpoint_exhausted:true (§3): %s", stdout.String())
|
||||
}
|
||||
// raw 字符串层面:data 必须是 [] 而非 null。
|
||||
if !strings.Contains(stdout.String(), `"data": []`) && !strings.Contains(stdout.String(), `"data":[]`) {
|
||||
t.Fatalf("data must serialize as [] on the wire: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"data": null`) || strings.Contains(stdout.String(), `"data":null`) {
|
||||
t.Fatalf("data must never be null (§2.5): %s", stdout.String())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+100
-11
@@ -64,17 +64,113 @@ var (
|
||||
)
|
||||
|
||||
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
|
||||
format, _ := resolveFormatWithWarning(cmd, fallback)
|
||||
return format
|
||||
}
|
||||
|
||||
// resolveFormatWithWarning 解析命令的 --format flag(B36,AC-09):已知值
|
||||
// 归一化为对应 Format 常量;未知非空值**降级 fallback 并产出一条 warning
|
||||
// 文本**(不崩不静默,契约规范 §5.2「未知值 → 降级 + stderr warning」)。
|
||||
// warning 的写出由调用方负责(信封出口 WriteEnvelope 写 cmd.ErrOrStderr()),
|
||||
// 本函数不做 I/O——ResolveFormat 的既有调用方(只读 format 值、不持有出口
|
||||
// 语义)行为保持不变。flag 缺席或值为空返回 fallback 且不产生 warning
|
||||
// (空值是「未指定」而非「未知」)。查找顺序与 ResolveFormat 一致:
|
||||
// cmd.Flags() 优先于 InheritedFlags(),命中 format flag 即返回。
|
||||
func resolveFormatWithWarning(cmd *cobra.Command, fallback Format) (Format, string) {
|
||||
if cmd == nil {
|
||||
return fallback, ""
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||
value, ok := formatValueFromFlagSet(flags)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
format := normalizeFormat(value, fallback)
|
||||
return format, unknownFormatWarning(value, format)
|
||||
}
|
||||
return fallback, ""
|
||||
}
|
||||
|
||||
// formatValueFromFlagSet 返回 flagSet 上 --format flag 的原始字符串值
|
||||
// (B35/B36/B43 共用的查找辅助)。flag 缺席或类型非 string 时返回 ok=false——
|
||||
// 与 formatFromFlagSet 的容错口径一致(不报错、交由 fallback 兜底)。
|
||||
func formatValueFromFlagSet(flags *pflag.FlagSet) (string, bool) {
|
||||
if flags == nil || flags.Lookup("format") == nil {
|
||||
return "", false
|
||||
}
|
||||
value, err := flags.GetString("format")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// unknownFormatWarning 在 raw 为非空未知值(归一化后不等于 resolved,即不是
|
||||
// 任何已知 format 的大小写/空白变体)时返回 warning 文本,否则返回空串
|
||||
// (B36,AC-09 不崩不静默)。文本只陈述事实(未知值 + 降级目标),
|
||||
// 不做过度承诺。
|
||||
func unknownFormatWarning(raw string, resolved Format) string {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(raw))
|
||||
if trimmed == "" || trimmed == string(resolved) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("unknown --format %q, falling back to %q", raw, string(resolved))
|
||||
}
|
||||
|
||||
// ResolveFormatWithJSONShorthand 解析命令的输出 format,实现 --json 简写语义
|
||||
// (B43,契约规范 §5.2:「--json 自动注册为 --format json 简写(显式
|
||||
// --format 优先)」)。优先级链:
|
||||
//
|
||||
// 1. 显式 --format(非空值)恒优先——含未知值(按 normalizeFormat 降级
|
||||
// fallback,--json 不 rescues 未知值);
|
||||
// 2. --format 缺席或为空时,--json 布尔 flag(Changed 且为 true)等价
|
||||
// --format json;
|
||||
// 3. 两者皆无 → fallback。
|
||||
//
|
||||
// --json 判定只接受 bool 型 flag(GetBool 成功):业务层存在同名 string
|
||||
// flag(如 table create --json 载荷参数)的命令不会被误判为简写。本函数
|
||||
// 纯判定不做 I/O,供不直接走 WriteEnvelope 的调用方(如主漏斗桥接,B44)
|
||||
// 复用;与 resolveFormatWithWarning 共用 formatValueFromFlagSet /
|
||||
// normalizeFormat,归一化规则单一事实源。
|
||||
func ResolveFormatWithJSONShorthand(cmd *cobra.Command, fallback Format) Format {
|
||||
if cmd == nil {
|
||||
return fallback
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||
if format, ok := formatFromFlagSet(flags, fallback); ok {
|
||||
return format
|
||||
value, ok := formatValueFromFlagSet(flags)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return normalizeFormat(value, fallback)
|
||||
}
|
||||
}
|
||||
if jsonShorthandActive(cmd) {
|
||||
return FormatJSON
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// jsonShorthandActive 报告 cmd 上是否有生效的 --json 简写(bool flag 被显式
|
||||
// 设置为 true)。查找 Flags / InheritedFlags / PersistentFlags 三组,与
|
||||
// internal/app 侧 commandRequestsJSONErrors 的判定面一致;GetBool 失败
|
||||
// (非 bool 同名 flag)跳过该组继续。
|
||||
func jsonShorthandActive(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()} {
|
||||
flag := flags.Lookup("json")
|
||||
if flag == nil || !flag.Changed {
|
||||
continue
|
||||
}
|
||||
if value, err := flags.GetBool("json"); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error {
|
||||
if cmd == nil {
|
||||
return Write(io.Discard, fallback, payload)
|
||||
@@ -127,15 +223,8 @@ func unwrapCompatRuntimePayload(payload any) any {
|
||||
}
|
||||
|
||||
func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) {
|
||||
if flags == nil {
|
||||
return fallback, false
|
||||
}
|
||||
flag := flags.Lookup("format")
|
||||
if flag == nil {
|
||||
return fallback, false
|
||||
}
|
||||
value, err := flags.GetString("format")
|
||||
if err != nil {
|
||||
value, ok := formatValueFromFlagSet(flags)
|
||||
if !ok {
|
||||
return fallback, false
|
||||
}
|
||||
return normalizeFormat(value, fallback), true
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type forgedResult struct {
|
||||
env Envelope
|
||||
exit int
|
||||
}
|
||||
|
||||
func (r forgedResult) Outcome() Outcome { return r.env.Outcome }
|
||||
func (r forgedResult) ExitCode() int { return r.exit }
|
||||
func (r forgedResult) envelope() *Envelope { copy := r.env; return © }
|
||||
|
||||
type cloneNode struct {
|
||||
Name string
|
||||
Next *cloneNode
|
||||
Values []string
|
||||
Hidden *string
|
||||
}
|
||||
|
||||
func TestFrameworkResultCloneAndValidationEdges(t *testing.T) {
|
||||
if cloneReflectValue(reflect.Value{}, nil).IsValid() {
|
||||
t.Fatal("invalid reflect value became valid")
|
||||
}
|
||||
var nilAny any
|
||||
if got := cloneResultData(nilAny); got != nil {
|
||||
t.Fatalf("cloneResultData(nil)=%v", got)
|
||||
}
|
||||
|
||||
node := &cloneNode{Name: "before", Values: []string{"one"}}
|
||||
node.Next = node
|
||||
payload := map[string]any{
|
||||
"node": node,
|
||||
"nil_ptr": (*cloneNode)(nil),
|
||||
"nil_map": map[string]string(nil),
|
||||
"nil_slice": []string(nil),
|
||||
"array": [2]string{"a", "b"},
|
||||
"interface": any([]string{"x"}),
|
||||
}
|
||||
cloned := cloneResultData(payload).(map[string]any)
|
||||
gotNode := cloned["node"].(*cloneNode)
|
||||
if gotNode == node || gotNode.Next != gotNode || gotNode.Values[0] != "one" {
|
||||
t.Fatalf("cycle-safe clone=%#v", gotNode)
|
||||
}
|
||||
node.Name, node.Values[0] = "after", "two"
|
||||
if gotNode.Name != "before" || gotNode.Values[0] != "one" {
|
||||
t.Fatalf("clone was mutated: %#v", gotNode)
|
||||
}
|
||||
|
||||
started := true
|
||||
retry := int64(3)
|
||||
info := &ErrorInfo{
|
||||
Type: "api", UpstreamCode: map[string]any{"token": "kept"},
|
||||
Params: []string{"p"}, Actions: []string{"a"}, AvailableFlags: []string{"f"},
|
||||
ExecutionStarted: &started, RetryAfterSeconds: &retry,
|
||||
Details: map[string]any{"nested": []any{"x"}}, RPCData: []any{"rpc"},
|
||||
}
|
||||
copyInfo := cloneErrorInfo(info)
|
||||
if copyInfo == info || copyInfo.ExecutionStarted == info.ExecutionStarted || copyInfo.RetryAfterSeconds == info.RetryAfterSeconds {
|
||||
t.Fatal("ErrorInfo pointer fields were not detached")
|
||||
}
|
||||
if cloneErrorInfo(nil) != nil {
|
||||
t.Fatal("cloneErrorInfo(nil) must be nil")
|
||||
}
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
clonedMap := cloneResultData(cyclicMap).(map[string]any)
|
||||
if reflect.ValueOf(clonedMap).Pointer() == reflect.ValueOf(cyclicMap).Pointer() {
|
||||
t.Fatal("cyclic map was not cloned")
|
||||
}
|
||||
cyclicSlice := make([]any, 1)
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
clonedSlice := cloneResultData(cyclicSlice).([]any)
|
||||
if reflect.ValueOf(clonedSlice).Pointer() == reflect.ValueOf(cyclicSlice).Pointer() {
|
||||
t.Fatal("cyclic slice was not cloned")
|
||||
}
|
||||
|
||||
validFailure := Failure(&ErrorInfo{Type: "api", Message: "failed"})
|
||||
if err := ValidateResult(validFailure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validPending := Pending(nil, &OperationInfo{ID: "task", State: "processing", NextCommand: "dws task get"})
|
||||
if err := ValidateResult(validPending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decorated := Success(nil, WithIdentity("user"), WithDryRun(), ResultOption{})
|
||||
if decorated.Outcome() != OutcomeSuccess {
|
||||
t.Fatalf("Outcome=%s", decorated.Outcome())
|
||||
}
|
||||
decoratedEnv, err := EnvelopeFromResult(decorated)
|
||||
if err != nil || decoratedEnv.Identity != "user" || !decoratedEnv.DryRun {
|
||||
t.Fatalf("decorated=%+v err=%v", decoratedEnv, err)
|
||||
}
|
||||
|
||||
badPending := []CommandResult{
|
||||
Pending(nil, nil),
|
||||
Pending(nil, &OperationInfo{State: "processing", NextCommand: "next"}),
|
||||
Pending(nil, &OperationInfo{ID: "id", NextCommand: "next"}),
|
||||
Pending(nil, &OperationInfo{ID: "id", State: "processing"}),
|
||||
}
|
||||
for _, result := range badPending {
|
||||
if ValidateResult(result) == nil {
|
||||
t.Fatalf("malformed pending accepted: %#v", result)
|
||||
}
|
||||
}
|
||||
if ValidateResult(Partial(nil)) == nil {
|
||||
t.Fatal("nil partial accepted")
|
||||
}
|
||||
if err := StoreResult(context.Background(), Pending(nil, nil)); err == nil {
|
||||
t.Fatal("StoreResult accepted malformed result")
|
||||
}
|
||||
if ValidateResult(forgedResult{env: Envelope{OK: true, Outcome: OutcomeSuccess}, exit: 9}) == nil {
|
||||
t.Fatal("forged exit code accepted")
|
||||
}
|
||||
badOverride := &commandResult{env: Envelope{OK: false, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}, exitCodeOverride: true}
|
||||
if ValidateResult(badOverride) == nil {
|
||||
t.Fatal("non-positive override accepted")
|
||||
}
|
||||
badWire := &commandResult{env: Envelope{OK: false, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api", ExitCode: 5}}, exitCode: 1}
|
||||
if ValidateResult(badWire) == nil {
|
||||
t.Fatal("wire/process exit mismatch accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type redactionEdge struct {
|
||||
Password *string `json:"password"`
|
||||
Plain string
|
||||
Ignored string `json:"-"`
|
||||
hidden string
|
||||
}
|
||||
|
||||
type redactCycle struct{ Next *redactCycle }
|
||||
|
||||
func TestFrameworkRedactionReflectionEdges(t *testing.T) {
|
||||
if redactEnvelope(nil) != nil {
|
||||
t.Fatal("redactEnvelope(nil) must be nil")
|
||||
}
|
||||
seen := map[redactVisit]struct{}{}
|
||||
redactReflectValue(reflect.Value{}, "", seen)
|
||||
|
||||
secret := "pointer-secret"
|
||||
fixture := &redactionEdge{Password: &secret, Plain: "token=plain-secret", Ignored: "ignored-secret", hidden: "hidden-secret"}
|
||||
redactReflectValue(reflect.ValueOf(fixture), "", seen) // not settable
|
||||
redactReflectValue(reflect.ValueOf(&fixture).Elem(), "", map[redactVisit]struct{}{})
|
||||
if fixture.Password == nil || *fixture.Password != redactedValue || !strings.Contains(fixture.Plain, redactedValue) {
|
||||
t.Fatalf("fixture not redacted: %+v", fixture)
|
||||
}
|
||||
if fixture.Ignored != "ignored-secret" || fixture.hidden != "hidden-secret" {
|
||||
t.Fatalf("ignored fields changed: %+v", fixture)
|
||||
}
|
||||
|
||||
array := [2]string{"password=array-one", "token=array-two"}
|
||||
redactReflectValue(reflect.ValueOf(&array).Elem(), "", map[redactVisit]struct{}{})
|
||||
if strings.Contains(strings.Join(array[:], ","), "array-") {
|
||||
t.Fatalf("array not redacted: %#v", array)
|
||||
}
|
||||
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
cyclicMap["password"] = "map-secret"
|
||||
redactReflectValue(reflect.ValueOf(cyclicMap), "", map[redactVisit]struct{}{})
|
||||
if cyclicMap["password"] != redactedValue {
|
||||
t.Fatalf("map secret=%v", cyclicMap["password"])
|
||||
}
|
||||
cyclicSlice := make([]any, 2)
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
cyclicSlice[1] = "password=slice-secret"
|
||||
redactReflectValue(reflect.ValueOf(cyclicSlice), "", map[redactVisit]struct{}{})
|
||||
pointerCycle := &redactCycle{}
|
||||
pointerCycle.Next = pointerCycle
|
||||
redactReflectValue(reflect.ValueOf(pointerCycle), "", map[redactVisit]struct{}{})
|
||||
|
||||
unsettable := reflect.ValueOf("secret")
|
||||
if setRedactedValue(unsettable) {
|
||||
t.Fatal("unsettable value was redacted")
|
||||
}
|
||||
settableString := reflect.New(reflect.TypeOf("")).Elem()
|
||||
if !setRedactedValue(settableString) || settableString.String() != redactedValue {
|
||||
t.Fatal("settable string was not redacted")
|
||||
}
|
||||
settablePointer := reflect.New(reflect.TypeOf((*string)(nil))).Elem()
|
||||
if !setRedactedValue(settablePointer) || settablePointer.Elem().String() != redactedValue {
|
||||
t.Fatal("settable pointer was not redacted")
|
||||
}
|
||||
settableInt := reflect.New(reflect.TypeOf(0)).Elem()
|
||||
if setRedactedValue(settableInt) {
|
||||
t.Fatal("integer unexpectedly redacted")
|
||||
}
|
||||
|
||||
text := redactRecognizableSecrets("?=unchanged https://u:p@example.test?normal=ok&signature=s")
|
||||
if !strings.Contains(text, "?=unchanged") || strings.Contains(text, "u:p") || strings.Contains(text, "signature=s") {
|
||||
t.Fatalf("recognized secret redaction=%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkEnvelopeNilAndNestedValidationEdges(t *testing.T) {
|
||||
if (&Pagination{}).Validate() == nil || (*Pagination)(nil).Validate() != nil {
|
||||
t.Fatal("pagination nil/two-state validation mismatch")
|
||||
}
|
||||
if (*ErrorInfo)(nil).Validate() == nil {
|
||||
t.Fatal("nil ErrorInfo accepted")
|
||||
}
|
||||
negative := int64(-1)
|
||||
for _, info := range []*ErrorInfo{
|
||||
{Type: "unknown"},
|
||||
{Type: "api", ExitCode: -1},
|
||||
{Type: "api", RetryAfterSeconds: &negative},
|
||||
} {
|
||||
if info.Validate() == nil {
|
||||
t.Fatalf("invalid ErrorInfo accepted: %+v", info)
|
||||
}
|
||||
}
|
||||
if (*PartialData)(nil).Validate() != nil {
|
||||
t.Fatal("nil PartialData should be compatible")
|
||||
}
|
||||
if (*OperationInfo)(nil).ValidateNextCommand() != nil || (*OperationInfo)(nil).ValidateTimeoutState() != nil || (*OperationInfo)(nil).Validate() != nil {
|
||||
t.Fatal("nil operation should validate")
|
||||
}
|
||||
env := NewSuccessEnvelope(nil)
|
||||
env.Meta = &Meta{Operation: &OperationInfo{NextCommand: " bad "}, Pagination: &Pagination{}}
|
||||
if env.Validate() == nil {
|
||||
t.Fatal("nested invalid metadata accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultStoreLifecycleEdges(t *testing.T) {
|
||||
if _, ok := resultStoreFromContext(nil); ok {
|
||||
t.Fatal("nil context had a store")
|
||||
}
|
||||
if err := ResetResultStore(context.Background()); err == nil {
|
||||
t.Fatal("ResetResultStore without root store succeeded")
|
||||
}
|
||||
ctx, store := WithResultStore(nil)
|
||||
ctx2, same := WithResultStore(ctx)
|
||||
if ctx2 != ctx || same != store {
|
||||
t.Fatal("WithResultStore did not reuse existing store")
|
||||
}
|
||||
if err := StoreResult(context.Background(), Success(nil)); err == nil {
|
||||
t.Fatal("StoreResult without root store succeeded")
|
||||
}
|
||||
if err := StoreResult(ctx, Success(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StoreResult(ctx, Success(nil)); err == nil {
|
||||
t.Fatal("duplicate result accepted")
|
||||
}
|
||||
if _, _, err := EmitStoredResult(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
if _, _, err := EmitStoredResult(plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain.SetContext(ctx)
|
||||
if _, _, err := EmitStoredResult(plain); err == nil {
|
||||
t.Fatal("legacy command emitted unified result")
|
||||
}
|
||||
|
||||
active := &cobra.Command{Use: "active"}
|
||||
active.SetContext(ctx)
|
||||
SetCommandRollout(active, RolloutUnifiedActive)
|
||||
var out bytes.Buffer
|
||||
active.SetOut(&out)
|
||||
code, emitted, err := EmitStoredResult(active)
|
||||
if err != nil || code != 0 || !emitted {
|
||||
t.Fatalf("EmitStoredResult=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
code, emitted, err = EmitStoredResult(active)
|
||||
if err != nil || code != 0 || !emitted {
|
||||
t.Fatalf("second EmitStoredResult=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
if code, attempted, emitted, risk := StoredEmissionState(store); code != 0 || !attempted || !emitted || !risk {
|
||||
t.Fatalf("state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if code, ok := StoredExitCode(store); code != 0 || !ok {
|
||||
t.Fatalf("StoredExitCode=(%d,%v)", code, ok)
|
||||
}
|
||||
if err := ResetResultStore(ctx); err != nil {
|
||||
t.Fatalf("ResetResultStore: %v", err)
|
||||
}
|
||||
if code, attempted, emitted, risk := StoredEmissionState(store); code != 0 || attempted || emitted || risk {
|
||||
t.Fatalf("reset state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if _, ok := StoredExitCode(store); ok {
|
||||
t.Fatal("reset store retained an emitted exit code")
|
||||
}
|
||||
if err := StoreResult(ctx, Success(map[string]any{"id": "after-reset"})); err != nil {
|
||||
t.Fatalf("StoreResult after reset: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultStoreNilStateAndWriteFailure(t *testing.T) {
|
||||
code, attempted, emitted, risk := StoredEmissionState(nil)
|
||||
if code != 0 || attempted || emitted || risk {
|
||||
t.Fatalf("nil state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if code, ok := StoredExitCode(nil); code != 0 || ok {
|
||||
t.Fatalf("nil exit=(%d,%v)", code, ok)
|
||||
}
|
||||
ctx, store := WithResultStore(context.Background())
|
||||
if err := StoreResult(ctx, Success(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "active"}
|
||||
cmd.SetContext(ctx)
|
||||
SetCommandRollout(cmd, RolloutUnifiedActive)
|
||||
cmd.SetOut(errorWriter{})
|
||||
if code, emitted, err := EmitStoredResult(cmd); code != exitCodeInternal || emitted || err == nil {
|
||||
t.Fatalf("write failure=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
if code, attempted, emitted, risk := StoredEmissionState(store); code != exitCodeInternal || !attempted || emitted || risk {
|
||||
t.Fatalf("failed state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if _, err := EnvelopeFromResult(nil); err == nil {
|
||||
t.Fatal("EnvelopeFromResult(nil) succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
type errorWriter struct{}
|
||||
|
||||
func (errorWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkEmitterAndRolloutEdges(t *testing.T) {
|
||||
if jsonShorthandActive(nil) {
|
||||
t.Fatal("nil command activated --json")
|
||||
}
|
||||
if err := WriteEnvelope(nil, nil, FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "cmd"}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(errorWriter{})
|
||||
cmd.Flags().String("format", "unknown", "")
|
||||
if err := WriteEnvelope(cmd, NewSuccessEnvelope(nil), FormatJSON); err == nil {
|
||||
t.Fatal("warning write error was ignored")
|
||||
}
|
||||
if _, _, err := emitResult(nil, nil); err == nil {
|
||||
t.Fatal("nil result accepted")
|
||||
}
|
||||
broken := NewSuccessEnvelope(make(chan int))
|
||||
if err := WriteEnvelope(&cobra.Command{Use: "broken"}, broken, FormatJSON); err == nil {
|
||||
t.Fatal("WriteEnvelope accepted unrenderable data")
|
||||
}
|
||||
if _, _, err := emitResult(&cobra.Command{Use: "broken"}, Success(make(chan int))); err == nil {
|
||||
t.Fatal("emitResult accepted unrenderable data")
|
||||
}
|
||||
|
||||
human := &cobra.Command{Use: "human"}
|
||||
human.Flags().String("format", "table", "")
|
||||
human.SetErr(errorWriter{})
|
||||
if _, risk, err := emitResult(human, Failure(&ErrorInfo{Type: "api", Message: "boom"})); err == nil || risk {
|
||||
t.Fatalf("human failure write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
short := &shortWriter{}
|
||||
human.SetErr(short)
|
||||
if _, risk, err := emitResult(human, Failure(&ErrorInfo{Type: "api"})); !errors.Is(err, io.ErrShortWrite) || !risk {
|
||||
t.Fatalf("human short write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
|
||||
machine := &cobra.Command{Use: "machine"}
|
||||
machine.SetOut(errorWriter{})
|
||||
if _, risk, err := emitResult(machine, Success(nil)); err == nil || risk {
|
||||
t.Fatalf("machine write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
machine.SetOut(&shortWriter{})
|
||||
if _, risk, err := emitResult(machine, Success(nil)); !errors.Is(err, io.ErrShortWrite) || !risk {
|
||||
t.Fatalf("machine short write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
|
||||
if err := renderEnvelope(io.Discard, nil, nil, FormatJSON, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fallback bytes.Buffer
|
||||
if err := renderEnvelopeInto(&fallback, io.Discard, NewSuccessEnvelope(map[string]any{"id": "x"}), Format("unexpected"), "", ""); err != nil || !strings.Contains(fallback.String(), `"outcome"`) {
|
||||
t.Fatalf("unknown format fallback=%q err=%v", fallback.String(), err)
|
||||
}
|
||||
if got := ExitCodeForEnvelope(&Envelope{Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "permission"}}); got != exitCodePermission {
|
||||
t.Fatalf("permission exit=%d", got)
|
||||
}
|
||||
var ndjson bytes.Buffer
|
||||
if err := renderEnvelopeInto(&ndjson, io.Discard, Failure(&ErrorInfo{Type: "api"}).envelope(), FormatNDJSON, "", ""); err != nil || strings.Count(strings.TrimSpace(ndjson.String()), "\n") != 0 {
|
||||
t.Fatalf("NDJSON failure=%q err=%v", ndjson.String(), err)
|
||||
}
|
||||
if _, err := writeAllCount(&shortWriter{}, []byte("payload")); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short write err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseRolloutState("bad"); err == nil {
|
||||
t.Fatal("invalid rollout parsed")
|
||||
}
|
||||
if err := ValidateRolloutTransition("bad", RolloutLegacyOnly, false); err == nil {
|
||||
t.Fatal("invalid from state accepted")
|
||||
}
|
||||
if err := ValidateRolloutTransition(RolloutLegacyOnly, "bad", false); err == nil {
|
||||
t.Fatal("invalid to state accepted")
|
||||
}
|
||||
if rolloutRank("bad") != -1 {
|
||||
t.Fatal("unknown rollout rank")
|
||||
}
|
||||
SetCommandRollout(nil, RolloutUnifiedActive)
|
||||
if err := ValidateUnifiedFormat(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("invalid rollout did not panic")
|
||||
}
|
||||
}()
|
||||
SetCommandRollout(&cobra.Command{Use: "bad"}, "bad")
|
||||
}
|
||||
|
||||
func TestFrameworkMCPAdapterErrors(t *testing.T) {
|
||||
if _, err := AdaptMCP(nil); err == nil {
|
||||
t.Fatal("AdaptMCP(nil) succeeded")
|
||||
}
|
||||
originalMarshal, originalUnmarshal := marshalMCPResult, unmarshalMCPResult
|
||||
t.Cleanup(func() { marshalMCPResult, unmarshalMCPResult = originalMarshal, originalUnmarshal })
|
||||
marshalMCPResult = func(any) ([]byte, error) { return nil, errors.New("marshal") }
|
||||
if _, err := AdaptMCP(Success(nil)); err == nil {
|
||||
t.Fatal("MCP marshal failure ignored")
|
||||
}
|
||||
marshalMCPResult = func(any) ([]byte, error) { return []byte("{"), nil }
|
||||
unmarshalMCPResult = originalUnmarshal
|
||||
if _, err := AdaptMCP(Success(nil)); err == nil {
|
||||
t.Fatal("MCP decode failure ignored")
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (*shortWriter) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(p) - 1, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
marshalMCPResult = json.Marshal
|
||||
unmarshalMCPResult = json.Unmarshal
|
||||
)
|
||||
|
||||
// MCPResult is the protocol-neutral projection consumed by MCP transports.
|
||||
// Both CLI and MCP adapters start from the same immutable CommandResult; MCP
|
||||
// handlers must not rebuild a second business envelope.
|
||||
type MCPResult struct {
|
||||
StructuredContent map[string]any `json:"structuredContent"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// AdaptMCP projects the exact unified envelope into MCP structuredContent. Failure
|
||||
// and partial failure set isError, while pending remains a successfully
|
||||
// accepted request whose terminal operation state is carried in the envelope.
|
||||
func AdaptMCP(result CommandResult) (*MCPResult, error) {
|
||||
env, err := EnvelopeFromResult(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env = redactEnvelope(env)
|
||||
raw, err := marshalMCPResult(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("output: marshal MCP result: %w", err)
|
||||
}
|
||||
var structured map[string]any
|
||||
if err := unmarshalMCPResult(raw, &structured); err != nil {
|
||||
return nil, fmt.Errorf("output: decode MCP structured result: %w", err)
|
||||
}
|
||||
return &MCPResult{
|
||||
StructuredContent: structured,
|
||||
IsError: env.Outcome == OutcomeFailure || env.Outcome == OutcomePartialFailure,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
)
|
||||
|
||||
const redactedValue = "[REDACTED]"
|
||||
|
||||
var (
|
||||
urlCredentialsPattern = regexp.MustCompile(`(?i)\b(https?://)[^/@\s]+@`)
|
||||
urlQueryValuePattern = regexp.MustCompile(`(?i)[?&][^=&#\s]+=[^&#\s"'<>]+`)
|
||||
headerSecretPattern = regexp.MustCompile(`(?im)\b(authorization|cookie|set-cookie)(\s*:\s*)[^\r\n]+`)
|
||||
jsonSecretPattern = regexp.MustCompile(`(?i)("(?:authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\s]+)`)
|
||||
assignedSecretPattern = regexp.MustCompile(`(?i)\b(authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)`)
|
||||
)
|
||||
|
||||
// redactEnvelope is the single pre-render boundary for framework-owned
|
||||
// diagnostics. Business data remains authoritative: commands such as explicit
|
||||
// credential reads intentionally return secrets and declare those fields in
|
||||
// ToolSpec.result.sensitive_paths. Error, operation, and notice channels are
|
||||
// never intentional secret-delivery surfaces and are always redacted.
|
||||
func redactEnvelope(source *Envelope) *Envelope {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
out := cloneEnvelope(*source)
|
||||
seen := make(map[redactVisit]struct{})
|
||||
redactReflectValue(reflect.ValueOf(&out.Meta), "meta", seen)
|
||||
redactReflectValue(reflect.ValueOf(&out.Error), "error", seen)
|
||||
redactReflectValue(reflect.ValueOf(&out.Notice), "_notice", seen)
|
||||
return &out
|
||||
}
|
||||
|
||||
type redactVisit struct {
|
||||
typ reflect.Type
|
||||
ptr uintptr
|
||||
}
|
||||
|
||||
func redactReflectValue(value reflect.Value, jsonKey string, seen map[redactVisit]struct{}) {
|
||||
if !value.IsValid() {
|
||||
return
|
||||
}
|
||||
if isSensitiveOutputKey(jsonKey) && setRedactedValue(value) {
|
||||
return
|
||||
}
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.Interface:
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
copy := reflect.New(value.Elem().Type()).Elem()
|
||||
copy.Set(value.Elem())
|
||||
redactReflectValue(copy, jsonKey, seen)
|
||||
value.Set(copy)
|
||||
case reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||
if _, ok := seen[visit]; ok {
|
||||
return
|
||||
}
|
||||
seen[visit] = struct{}{}
|
||||
redactReflectValue(value.Elem(), jsonKey, seen)
|
||||
case reflect.Map:
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||
if _, ok := seen[visit]; ok {
|
||||
return
|
||||
}
|
||||
seen[visit] = struct{}{}
|
||||
iter := value.MapRange()
|
||||
for iter.Next() {
|
||||
key, item := iter.Key(), iter.Value()
|
||||
copy := reflect.New(item.Type()).Elem()
|
||||
copy.Set(item)
|
||||
name := ""
|
||||
if key.Kind() == reflect.String {
|
||||
name = key.String()
|
||||
}
|
||||
redactReflectValue(copy, name, seen)
|
||||
value.SetMapIndex(key, copy)
|
||||
}
|
||||
case reflect.Slice:
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||
if _, ok := seen[visit]; ok {
|
||||
return
|
||||
}
|
||||
seen[visit] = struct{}{}
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
redactReflectValue(value.Index(i), "", seen)
|
||||
}
|
||||
case reflect.Array:
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
redactReflectValue(value.Index(i), "", seen)
|
||||
}
|
||||
case reflect.Struct:
|
||||
typ := value.Type()
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
fieldInfo := typ.Field(i)
|
||||
if fieldInfo.PkgPath != "" || fieldInfo.Tag.Get("json") == "-" {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(fieldInfo.Tag.Get("json"), ",")[0]
|
||||
if name == "" {
|
||||
name = fieldInfo.Name
|
||||
}
|
||||
redactReflectValue(value.Field(i), name, seen)
|
||||
}
|
||||
case reflect.String:
|
||||
if value.CanSet() {
|
||||
value.SetString(redactRecognizableSecrets(value.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setRedactedValue(value reflect.Value) bool {
|
||||
if !value.CanSet() {
|
||||
return false
|
||||
}
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
value.SetString(redactedValue)
|
||||
return true
|
||||
case reflect.Interface:
|
||||
replacement := reflect.ValueOf(redactedValue)
|
||||
if replacement.Type().AssignableTo(value.Type()) || replacement.Type().Implements(value.Type()) {
|
||||
value.Set(replacement)
|
||||
return true
|
||||
}
|
||||
case reflect.Pointer:
|
||||
replacement := reflect.New(value.Type().Elem())
|
||||
if setRedactedValue(replacement.Elem()) {
|
||||
value.Set(replacement)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isSensitiveOutputKey reports whether a diagnostics-channel key holds a
|
||||
// credential. It reuses logging.IsSensitiveKey semantics (case-insensitive;
|
||||
// exact snake/kebab names such as api_key / client-secret, plus any key
|
||||
// containing secret/token/credential/password, which also covers camelCase
|
||||
// variants like clientSecret) so error details, RPC data, and notices follow
|
||||
// the same sensitive-key boundary as the logging pipeline. The header-only
|
||||
// "set-cookie" variant is not in the logging list and stays covered here.
|
||||
func isSensitiveOutputKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
switch normalized {
|
||||
case "set-cookie":
|
||||
// Header-only variant the logging list does not cover.
|
||||
return true
|
||||
case "next_token":
|
||||
// meta.pagination.next_token is the resumable-pagination handle an
|
||||
// Agent must read (§3 two-state semantics): it is an opaque cursor,
|
||||
// not a credential, and stays visible across every channel.
|
||||
return false
|
||||
}
|
||||
return logging.IsSensitiveKey(normalized)
|
||||
}
|
||||
|
||||
func redactRecognizableSecrets(text string) string {
|
||||
text = urlCredentialsPattern.ReplaceAllString(text, `${1}`+redactedValue+`@`)
|
||||
text = urlQueryValuePattern.ReplaceAllStringFunc(text, func(parameter string) string {
|
||||
equals := strings.IndexByte(parameter, '=')
|
||||
key, err := url.QueryUnescape(parameter[1:equals])
|
||||
if err != nil || !isSensitiveQueryKey(key) {
|
||||
return parameter
|
||||
}
|
||||
return parameter[:equals+1] + redactedValue
|
||||
})
|
||||
text = headerSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
|
||||
text = jsonSecretPattern.ReplaceAllString(text, `${1}"`+redactedValue+`"`)
|
||||
return assignedSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
|
||||
}
|
||||
|
||||
func isSensitiveQueryKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
return isSensitiveOutputKey(normalized) || strings.Contains(normalized, "signature")
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type redactionFixture struct {
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"client_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Nested map[string]any `json:"nested"`
|
||||
}
|
||||
|
||||
func TestWriteEnvelopePreservesDeclaredBusinessDataAndRedactsFrameworkChannels(t *testing.T) {
|
||||
data := &redactionFixture{
|
||||
Password: "data-password-canary",
|
||||
ClientID: "client-id-canary",
|
||||
RequestID: "request-id-canary",
|
||||
Nested: map[string]any{
|
||||
"ToKeN": "nested-token-canary",
|
||||
"items": []any{
|
||||
map[string]string{"access_token": "slice-token-canary"},
|
||||
"https://user:url-password-canary@example.test/path?x=usable&x-oss-signature=url-signature-canary",
|
||||
},
|
||||
},
|
||||
}
|
||||
notice := map[string]any{"cookie": "notice-cookie-canary", "message": "token=notice-text-canary"}
|
||||
env := &Envelope{
|
||||
OK: true,
|
||||
Outcome: OutcomeSuccess,
|
||||
Data: data,
|
||||
Meta: &Meta{Operation: &OperationInfo{
|
||||
ID: "task-id-canary",
|
||||
State: OperationStateProcessing,
|
||||
NextCommand: "dws op get 'https://example.test/poll?token=next-command-canary&safe=yes'",
|
||||
}},
|
||||
Notice: notice,
|
||||
}
|
||||
originalData := cloneResultData(data)
|
||||
originalNotice := cloneResultData(notice)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
output := buf.String()
|
||||
for _, secret := range []string{
|
||||
"notice-cookie-canary", "notice-text-canary", "next-command-canary",
|
||||
} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Errorf("output leaked %q: %s", secret, output)
|
||||
}
|
||||
}
|
||||
for _, retained := range []string{
|
||||
"data-password-canary", "nested-token-canary", "slice-token-canary",
|
||||
"url-password-canary", "url-signature-canary", "client-id-canary",
|
||||
"request-id-canary", "task-id-canary", "x=usable", "safe=yes",
|
||||
} {
|
||||
if !strings.Contains(output, retained) {
|
||||
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(output, redactedValue); got < 3 {
|
||||
t.Errorf("redaction placeholder count = %d, want at least 3: %s", got, output)
|
||||
}
|
||||
if !reflect.DeepEqual(data, originalData) || !reflect.DeepEqual(notice, originalNotice) {
|
||||
t.Fatalf("redaction mutated caller values\ndata: %#v\nnotice: %#v", data, notice)
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("caller envelope became invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitResultRedactsEveryErrorInfoCanary(t *testing.T) {
|
||||
info := &ErrorInfo{
|
||||
Type: "api",
|
||||
Message: "Authorization: Bearer message-auth-canary",
|
||||
Hint: "use https://hint-user:hint-pass-canary@example.test/help?access_token=hint-query-canary&doc=usable",
|
||||
Details: map[string]any{"PASSWORD": "details-password-canary"},
|
||||
RPCData: map[string]any{"client_secret": "rpc-secret-canary", "task_id": "rpc-task-id-canary"},
|
||||
TechnicalDetail: `upstream {"refresh_token":"technical-token-canary"}`,
|
||||
FriendlyHint: "cookie=friendly-cookie-canary",
|
||||
ActionURL: "https://example.test/retry?signature=action-signature-canary&step=2",
|
||||
Cause: "token:cause-token-canary",
|
||||
Actions: []string{"dws retry --url 'https://example.test/run?x-oss-signature=actions-signature-canary&mode=safe'"},
|
||||
RequestID: "error-request-id-canary",
|
||||
}
|
||||
original := cloneErrorInfo(info)
|
||||
result := Failure(info)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
code, err := EmitResult(cmd, result)
|
||||
if err != nil {
|
||||
t.Fatalf("EmitResult: %v", err)
|
||||
}
|
||||
if code != exitCodeAPI {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitCodeAPI)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, secret := range []string{
|
||||
"message-auth-canary", "hint-pass-canary", "hint-query-canary",
|
||||
"details-password-canary", "rpc-secret-canary", "technical-token-canary",
|
||||
"friendly-cookie-canary", "action-signature-canary", "cause-token-canary",
|
||||
"actions-signature-canary",
|
||||
} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Errorf("output leaked %q: %s", secret, output)
|
||||
}
|
||||
}
|
||||
for _, retained := range []string{"error-request-id-canary", "rpc-task-id-canary", "doc=usable", "step=2", "mode=safe"} {
|
||||
if !strings.Contains(output, retained) {
|
||||
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||
}
|
||||
}
|
||||
var decoded Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output is not a typed envelope: %v", err)
|
||||
}
|
||||
if err := decoded.Validate(); err != nil {
|
||||
t.Fatalf("redacted envelope is invalid: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(info, original) {
|
||||
t.Fatalf("EmitResult mutated ErrorInfo\ngot: %#v\nwant: %#v", info, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitResultRedactsLoggingAlignedSensitiveKeys locks the aligned
|
||||
// sensitive-key boundary (logging.IsSensitiveKey semantics) for the error
|
||||
// channel: snake_case (api_key), kebab-case (client-secret), camelCase
|
||||
// (clientSecret), and any key containing secret/token/credential must be
|
||||
// redacted in Details and RPCData, while ordinary identifiers survive.
|
||||
func TestEmitResultRedactsLoggingAlignedSensitiveKeys(t *testing.T) {
|
||||
info := &ErrorInfo{
|
||||
Type: "api",
|
||||
Message: "request failed",
|
||||
Details: map[string]any{
|
||||
"api_key": "details-api-key-canary",
|
||||
"client-secret": "details-client-secret-canary",
|
||||
"credential_id": "details-credential-id-canary",
|
||||
"clientSecret": "details-camel-secret-canary",
|
||||
"resource_id": "details-resource-id-canary",
|
||||
},
|
||||
RPCData: map[string]any{
|
||||
"api_key": "rpc-api-key-canary",
|
||||
"credential_id": "rpc-credential-id-canary",
|
||||
"task_id": "rpc-task-id-canary",
|
||||
},
|
||||
}
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
if _, err := EmitResult(cmd, Failure(info)); err != nil {
|
||||
t.Fatalf("EmitResult: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, secret := range []string{
|
||||
"details-api-key-canary", "details-client-secret-canary",
|
||||
"details-credential-id-canary", "details-camel-secret-canary",
|
||||
"rpc-api-key-canary", "rpc-credential-id-canary",
|
||||
} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Errorf("output leaked %q: %s", secret, output)
|
||||
}
|
||||
}
|
||||
for _, retained := range []string{"details-resource-id-canary", "rpc-task-id-canary"} {
|
||||
if !strings.Contains(output, retained) {
|
||||
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteEnvelopeRedactsNoticeSensitiveKeys applies the same aligned
|
||||
// boundary to the _notice channel, and locks the pagination exemption:
|
||||
// meta.pagination.next_token is the resumable cursor an Agent must read and
|
||||
// stays visible.
|
||||
func TestWriteEnvelopeRedactsNoticeSensitiveKeys(t *testing.T) {
|
||||
env := NewSuccessEnvelope(map[string]any{"id": "1"})
|
||||
env.Meta = &Meta{Pagination: &Pagination{EndpointExhausted: false, NextToken: "pagination-cursor-canary"}}
|
||||
env.Notice = map[string]any{
|
||||
"api_key": "notice-api-key-canary",
|
||||
"client-secret": "notice-client-secret-canary",
|
||||
"credential_id": "notice-credential-id-canary",
|
||||
"message": "notice-message-canary",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||
}
|
||||
output := buf.String()
|
||||
for _, secret := range []string{
|
||||
"notice-api-key-canary", "notice-client-secret-canary", "notice-credential-id-canary",
|
||||
} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Errorf("output leaked %q: %s", secret, output)
|
||||
}
|
||||
}
|
||||
for _, retained := range []string{"notice-message-canary", "pagination-cursor-canary"} {
|
||||
if !strings.Contains(output, retained) {
|
||||
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSensitiveOutputKeyBoundary locks the aligned key classification:
|
||||
// logging.IsSensitiveKey variants (snake/kebab/camel and secret/token/
|
||||
// credential/password substrings) are sensitive, the header-only set-cookie
|
||||
// stays covered, and the pagination cursor next_token stays visible.
|
||||
func TestIsSensitiveOutputKeyBoundary(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"api_key", "api-key", "client-secret", "clientSecret", "credential_id",
|
||||
"set-cookie", "Authorization", "password", "x-user-access-token",
|
||||
} {
|
||||
if !isSensitiveOutputKey(key) {
|
||||
t.Errorf("isSensitiveOutputKey(%q) = false, want true", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"next_token", "resource_id", "task_id", "name", ""} {
|
||||
if isSensitiveOutputKey(key) {
|
||||
t.Errorf("isSensitiveOutputKey(%q) = true, want false", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitResultHumanFailureRedactsBeforeShortcutRendering(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("format", "table", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
_, err := EmitResult(cmd, Failure(&ErrorInfo{Type: "api", Message: "password=human-failure-canary"}))
|
||||
if err != nil {
|
||||
t.Fatalf("EmitResult: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "human-failure-canary") || !strings.Contains(stderr.String(), redactedValue) {
|
||||
t.Fatalf("human failure was not redacted: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptMCPPreservesBusinessData(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"authorization": "mcp-auth-canary",
|
||||
"client_id": "mcp-client-id-canary",
|
||||
}
|
||||
result := Success(data)
|
||||
mcpResult, err := AdaptMCP(result)
|
||||
if err != nil {
|
||||
t.Fatalf("AdaptMCP: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(mcpResult)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal MCPResult: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "mcp-auth-canary") {
|
||||
t.Fatalf("MCP business data was unexpectedly redacted: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "mcp-client-id-canary") {
|
||||
t.Fatalf("MCP output over-redacted client_id: %s", raw)
|
||||
}
|
||||
if data["authorization"] != "mcp-auth-canary" {
|
||||
t.Fatalf("AdaptMCP mutated caller data: %#v", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandResult is the immutable framework result consumed by CLI and MCP
|
||||
// adapters. The unexported envelope method prevents product packages from
|
||||
// implementing ad-hoc result shapes.
|
||||
type CommandResult interface {
|
||||
Outcome() Outcome
|
||||
ExitCode() int
|
||||
envelope() *Envelope
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
env Envelope
|
||||
exitCode int
|
||||
exitCodeOverride bool
|
||||
}
|
||||
|
||||
func (r *commandResult) Outcome() Outcome { return r.env.Outcome }
|
||||
func (r *commandResult) ExitCode() int { return r.exitCode }
|
||||
func (r *commandResult) envelope() *Envelope {
|
||||
copy := cloneEnvelope(r.env)
|
||||
return ©
|
||||
}
|
||||
|
||||
// ResultOption enriches a result without exposing mutable framework fields.
|
||||
type ResultOption struct{ apply func(*Envelope) }
|
||||
|
||||
func WithIdentity(identity string) ResultOption {
|
||||
return ResultOption{apply: func(env *Envelope) { env.Identity = identity }}
|
||||
}
|
||||
|
||||
func WithDryRun() ResultOption {
|
||||
return ResultOption{apply: func(env *Envelope) { env.DryRun = true }}
|
||||
}
|
||||
|
||||
func WithMeta(meta *Meta) ResultOption {
|
||||
return ResultOption{apply: func(env *Envelope) { env.Meta = cloneMeta(meta) }}
|
||||
}
|
||||
|
||||
// Success constructs an immutable success result.
|
||||
func Success(data any, opts ...ResultOption) CommandResult {
|
||||
return newCommandResult(OutcomeSuccess, data, nil, opts...)
|
||||
}
|
||||
|
||||
// Pending constructs an immutable accepted-but-not-terminal result.
|
||||
func Pending(data any, operation *OperationInfo, opts ...ResultOption) CommandResult {
|
||||
opts = append(opts, ResultOption{apply: func(env *Envelope) {
|
||||
if env.Meta == nil {
|
||||
env.Meta = &Meta{}
|
||||
}
|
||||
env.Meta.Operation = operation
|
||||
}})
|
||||
return newCommandResult(OutcomePending, data, nil, opts...)
|
||||
}
|
||||
|
||||
// Partial constructs an immutable multi-status result with exit code 7.
|
||||
func Partial(data *PartialData, opts ...ResultOption) CommandResult {
|
||||
return newCommandResult(OutcomePartialFailure, data, nil, opts...)
|
||||
}
|
||||
|
||||
// Failure constructs an immutable typed failure result.
|
||||
func Failure(info *ErrorInfo, opts ...ResultOption) CommandResult {
|
||||
if info != nil {
|
||||
info = cloneErrorInfo(info)
|
||||
// Product code does not own process status. The framework derives it
|
||||
// from type/subtype so wire and process status cannot drift.
|
||||
info.ExitCode = 0
|
||||
}
|
||||
return newCommandResult(OutcomeFailure, nil, info, opts...)
|
||||
}
|
||||
|
||||
// FailureWithExitCode adapts an existing repository error whose historical
|
||||
// process status is part of a compatibility contract (for example SIGINT=130).
|
||||
// Product handlers must use Failure; only the root legacy-error adapter should
|
||||
// call this function.
|
||||
func FailureWithExitCode(info *ErrorInfo, exitCode int, opts ...ResultOption) CommandResult {
|
||||
if info != nil {
|
||||
info = cloneErrorInfo(info)
|
||||
info.ExitCode = 0
|
||||
}
|
||||
return newCommandResultWithExitCode(OutcomeFailure, nil, info, exitCode, true, opts...)
|
||||
}
|
||||
|
||||
func newCommandResult(outcome Outcome, data any, info *ErrorInfo, opts ...ResultOption) CommandResult {
|
||||
return newCommandResultWithExitCode(outcome, data, info, 0, false, opts...)
|
||||
}
|
||||
|
||||
func newCommandResultWithExitCode(outcome Outcome, data any, info *ErrorInfo, override int, hasOverride bool, opts ...ResultOption) CommandResult {
|
||||
env := Envelope{Outcome: outcome, Data: data, Error: info}
|
||||
env.OK = outcome == OutcomeSuccess || outcome == OutcomePending
|
||||
for _, opt := range opts {
|
||||
if opt.apply != nil {
|
||||
opt.apply(&env)
|
||||
}
|
||||
}
|
||||
exitCode := ExitCodeForEnvelope(&env)
|
||||
if hasOverride && override > 0 {
|
||||
exitCode = override
|
||||
}
|
||||
if env.Error != nil {
|
||||
env.Error.ExitCode = exitCode
|
||||
}
|
||||
env = cloneEnvelope(env)
|
||||
return &commandResult{env: env, exitCode: exitCode, exitCodeOverride: hasOverride}
|
||||
}
|
||||
|
||||
func cloneEnvelope(source Envelope) Envelope {
|
||||
out := source
|
||||
out.Meta = cloneMeta(source.Meta)
|
||||
out.Error = cloneErrorInfo(source.Error)
|
||||
out.Data = cloneResultData(source.Data)
|
||||
out.Notice = cloneResultData(source.Notice)
|
||||
if partial, ok := source.Data.(*PartialData); ok && partial != nil {
|
||||
copyPartial := &PartialData{
|
||||
Total: partial.Total,
|
||||
Succeeded: make([]any, len(partial.Succeeded)),
|
||||
Failed: make([]PartialFailedEntry, len(partial.Failed)),
|
||||
Unknown: make([]PartialUnknownEntry, len(partial.Unknown)),
|
||||
}
|
||||
copy(copyPartial.Failed, partial.Failed)
|
||||
copy(copyPartial.Unknown, partial.Unknown)
|
||||
for i := range partial.Succeeded {
|
||||
copyPartial.Succeeded[i] = cloneResultData(partial.Succeeded[i])
|
||||
}
|
||||
for i := range copyPartial.Failed {
|
||||
copyPartial.Failed[i].Error = cloneErrorInfo(copyPartial.Failed[i].Error)
|
||||
}
|
||||
out.Data = copyPartial
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMeta(source *Meta) *Meta {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
meta := *source
|
||||
if source.Count != nil {
|
||||
count := *source.Count
|
||||
meta.Count = &count
|
||||
}
|
||||
if source.Operation != nil {
|
||||
op := *source.Operation
|
||||
meta.Operation = &op
|
||||
}
|
||||
if source.Pagination != nil {
|
||||
pagination := *source.Pagination
|
||||
meta.Pagination = &pagination
|
||||
}
|
||||
return &meta
|
||||
}
|
||||
|
||||
func cloneResultData(value any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneReflectValue(reflect.ValueOf(value), make(map[cloneVisit]reflect.Value)).Interface()
|
||||
}
|
||||
|
||||
type cloneVisit struct {
|
||||
typ reflect.Type
|
||||
ptr uintptr
|
||||
len int
|
||||
cap int
|
||||
}
|
||||
|
||||
func cloneReflectValue(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value {
|
||||
if !value.IsValid() {
|
||||
return value
|
||||
}
|
||||
switch value.Kind() {
|
||||
case reflect.Interface:
|
||||
if value.IsNil() {
|
||||
return reflect.Zero(value.Type())
|
||||
}
|
||||
cloned := cloneReflectValue(value.Elem(), seen)
|
||||
out := reflect.New(value.Type()).Elem()
|
||||
out.Set(cloned)
|
||||
return out
|
||||
case reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return reflect.Zero(value.Type())
|
||||
}
|
||||
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||
if cloned, ok := seen[visit]; ok {
|
||||
return cloned
|
||||
}
|
||||
out := reflect.New(value.Type().Elem())
|
||||
seen[visit] = out
|
||||
out.Elem().Set(cloneReflectValue(value.Elem(), seen))
|
||||
return out
|
||||
case reflect.Map:
|
||||
if value.IsNil() {
|
||||
return reflect.Zero(value.Type())
|
||||
}
|
||||
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||
if cloned, ok := seen[visit]; ok {
|
||||
return cloned
|
||||
}
|
||||
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
||||
seen[visit] = out
|
||||
iter := value.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(cloneReflectValue(iter.Key(), seen), cloneReflectValue(iter.Value(), seen))
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if value.IsNil() {
|
||||
return reflect.Zero(value.Type())
|
||||
}
|
||||
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer(), len: value.Len(), cap: value.Cap()}
|
||||
if cloned, ok := seen[visit]; ok {
|
||||
return cloned
|
||||
}
|
||||
out := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
||||
seen[visit] = out
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
|
||||
}
|
||||
return out
|
||||
case reflect.Array:
|
||||
out := reflect.New(value.Type()).Elem()
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
|
||||
}
|
||||
return out
|
||||
case reflect.Struct:
|
||||
out := reflect.New(value.Type()).Elem()
|
||||
out.Set(value)
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
if value.Type().Field(i).PkgPath == "" && out.Field(i).CanSet() {
|
||||
out.Field(i).Set(cloneReflectValue(value.Field(i), seen))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func cloneErrorInfo(source *ErrorInfo) *ErrorInfo {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
out := *source
|
||||
out.UpstreamCode = cloneResultData(source.UpstreamCode)
|
||||
out.Params = append([]string(nil), source.Params...)
|
||||
out.Actions = append([]string(nil), source.Actions...)
|
||||
out.AvailableFlags = append([]string(nil), source.AvailableFlags...)
|
||||
if source.ExecutionStarted != nil {
|
||||
started := *source.ExecutionStarted
|
||||
out.ExecutionStarted = &started
|
||||
}
|
||||
if source.Details != nil {
|
||||
out.Details = cloneResultData(source.Details).(map[string]any)
|
||||
}
|
||||
out.RPCData = cloneResultData(source.RPCData)
|
||||
if source.RetryAfterSeconds != nil {
|
||||
seconds := *source.RetryAfterSeconds
|
||||
out.RetryAfterSeconds = &seconds
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// ValidateResult is the mandatory pre-emission policy boundary.
|
||||
func ValidateResult(result CommandResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("output: nil command result")
|
||||
}
|
||||
env := result.envelope()
|
||||
if err := env.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if env.Outcome == OutcomePending {
|
||||
if env.Meta == nil || env.Meta.Operation == nil {
|
||||
return fmt.Errorf("output: pending result requires meta.operation")
|
||||
}
|
||||
op := env.Meta.Operation
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return fmt.Errorf("output: pending result requires operation.id")
|
||||
}
|
||||
if strings.TrimSpace(op.State) == "" {
|
||||
return fmt.Errorf("output: pending result requires operation.state")
|
||||
}
|
||||
if strings.TrimSpace(op.NextCommand) == "" {
|
||||
return fmt.Errorf("output: pending result requires operation.next_command")
|
||||
}
|
||||
}
|
||||
if env.Outcome == OutcomePartialFailure {
|
||||
if partial, ok := env.Data.(*PartialData); !ok || partial == nil {
|
||||
return fmt.Errorf("output: partial_failure result requires typed *PartialData")
|
||||
}
|
||||
}
|
||||
concrete, _ := result.(*commandResult)
|
||||
want := ExitCodeForEnvelope(env)
|
||||
if concrete == nil || !concrete.exitCodeOverride {
|
||||
if got := result.ExitCode(); got != want {
|
||||
return fmt.Errorf("output: result exit code=%d disagrees with outcome-derived code=%d", got, want)
|
||||
}
|
||||
} else if result.ExitCode() <= 0 {
|
||||
return fmt.Errorf("output: explicit compatibility exit code must be positive")
|
||||
}
|
||||
if env.Error != nil && env.Error.ExitCode != result.ExitCode() {
|
||||
return fmt.Errorf("output: error.exit_code=%d disagrees with process exit code=%d", env.Error.ExitCode, result.ExitCode())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResultStore transfers a successful in-memory result through Cobra without
|
||||
// pretending it is an error. The root PersistentPostRunE emits the result
|
||||
// before closing output sinks, so normal cleanup always runs.
|
||||
type ResultStore struct {
|
||||
mu sync.Mutex
|
||||
result CommandResult
|
||||
emitAttempted bool
|
||||
bytesRisk bool
|
||||
emitted bool
|
||||
exitCode int
|
||||
}
|
||||
|
||||
type resultStoreContextKey struct{}
|
||||
|
||||
func WithResultStore(ctx context.Context) (context.Context, *ResultStore) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if store, ok := resultStoreFromContext(ctx); ok {
|
||||
return ctx, store
|
||||
}
|
||||
store := &ResultStore{}
|
||||
return context.WithValue(ctx, resultStoreContextKey{}, store), store
|
||||
}
|
||||
|
||||
func resultStoreFromContext(ctx context.Context) (*ResultStore, bool) {
|
||||
if ctx == nil {
|
||||
return nil, false
|
||||
}
|
||||
store, ok := ctx.Value(resultStoreContextKey{}).(*ResultStore)
|
||||
return store, ok && store != nil
|
||||
}
|
||||
|
||||
// ResetResultStore starts a new command execution on a reusable Cobra tree.
|
||||
//
|
||||
// NewRootCommand installs one store in the root context so process-level
|
||||
// signal and exit-code tracking can retain a stable pointer. Library callers,
|
||||
// however, may call ExecuteC more than once on that same root. Resetting the
|
||||
// execution fields before PersistentPreRunE prevents a previous result or
|
||||
// emission attempt from leaking into the next invocation while preserving the
|
||||
// store identity used by the process boundary.
|
||||
func ResetResultStore(ctx context.Context) error {
|
||||
store, ok := resultStoreFromContext(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("output: command context has no result store; install output.WithResultStore at the root execution boundary before resetting it")
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.result = nil
|
||||
store.emitAttempted = false
|
||||
store.bytesRisk = false
|
||||
store.emitted = false
|
||||
store.exitCode = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreResult records exactly one terminal framework result for this command.
|
||||
func StoreResult(ctx context.Context, result CommandResult) error {
|
||||
if err := ValidateResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
store, ok := resultStoreFromContext(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("output: command context has no result store; install output.WithResultStore at the root execution boundary before calling StoreResult")
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if store.result != nil {
|
||||
return fmt.Errorf("output: command produced more than one framework result")
|
||||
}
|
||||
store.result = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmitStoredResult is called from PersistentPostRunE before output cleanup.
|
||||
func EmitStoredResult(cmd *cobra.Command) (int, bool, error) {
|
||||
if cmd == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
store, ok := resultStoreFromContext(cmd.Context())
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if store.result == nil || store.emitAttempted {
|
||||
return store.exitCode, store.emitted, nil
|
||||
}
|
||||
if !UsesUnifiedResult(cmd) {
|
||||
return 0, false, fmt.Errorf("output: legacy command %q produced a unified framework result", cmd.CommandPath())
|
||||
}
|
||||
store.emitAttempted = true
|
||||
store.exitCode = store.result.ExitCode()
|
||||
code, bytesRisk, err := emitResult(cmd, store.result)
|
||||
store.bytesRisk = bytesRisk
|
||||
if err != nil {
|
||||
store.exitCode = exitCodeInternal
|
||||
if !bytesRisk {
|
||||
// emitResult renders into memory before touching stdout. A rendering
|
||||
// failure therefore has no externally visible bytes and can safely be
|
||||
// replaced by one minimal typed failure. Write failures keep
|
||||
// bytesRisk=true and must never attempt a second primary result.
|
||||
fallback := Failure(&ErrorInfo{
|
||||
Type: "internal",
|
||||
Subtype: "result_encoding_failed",
|
||||
Message: "failed to encode command result",
|
||||
TechnicalDetail: err.Error(),
|
||||
})
|
||||
fallbackCode, fallbackBytesRisk, fallbackErr := emitResult(cmd, fallback)
|
||||
store.bytesRisk = fallbackBytesRisk
|
||||
if fallbackErr == nil {
|
||||
store.exitCode = fallbackCode
|
||||
store.emitted = true
|
||||
return fallbackCode, true, nil
|
||||
}
|
||||
return store.exitCode, false, fmt.Errorf("output: render command result: %v; emit fallback: %w", err, fallbackErr)
|
||||
}
|
||||
// The writer may have consumed bytes even when it returned an error.
|
||||
// Preserve the single-primary-result invariant and surface the write
|
||||
// failure only as a diagnostic plus the stored internal exit code.
|
||||
return store.exitCode, false, err
|
||||
}
|
||||
store.exitCode = code
|
||||
store.emitted = true
|
||||
return code, true, nil
|
||||
}
|
||||
|
||||
// StoredEmissionState reports whether result emission was attempted. Once an
|
||||
// attempt starts, callers must not try another envelope because a writer may
|
||||
// have accepted bytes even when it returned an error.
|
||||
func StoredEmissionState(store *ResultStore) (exitCode int, attempted, emitted, bytesRisk bool) {
|
||||
if store == nil {
|
||||
return 0, false, false, false
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.exitCode, store.emitAttempted, store.emitted, store.bytesRisk
|
||||
}
|
||||
|
||||
// StoredExitCode returns the code produced by PersistentPostRunE.
|
||||
func StoredExitCode(store *ResultStore) (int, bool) {
|
||||
if store == nil {
|
||||
return 0, false
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.exitCode, store.emitted
|
||||
}
|
||||
|
||||
// EnvelopeFromResult returns a detached envelope copy for adapters/tests.
|
||||
func EnvelopeFromResult(result CommandResult) (*Envelope, error) {
|
||||
if err := ValidateResult(result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.envelope(), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ContractMode is an internal renderer identity. It is deliberately not a CLI
|
||||
// flag: one command has exactly one externally active contract in one release.
|
||||
type ContractMode string
|
||||
|
||||
const (
|
||||
ContractLegacy ContractMode = "legacy"
|
||||
// ContractUnified is the single framework-owned result shape. It is not a
|
||||
// consumer-selectable protocol version: a command either still renders its
|
||||
// historical legacy shape or has been promoted to the unified result.
|
||||
ContractUnified ContractMode = "unified"
|
||||
)
|
||||
|
||||
// RolloutState is internal release metadata. Consumers never select it. A
|
||||
// command advances through validation and activation; rollback is performed by
|
||||
// changing the command declaration/release, not by changing Agent argv.
|
||||
type RolloutState string
|
||||
|
||||
const (
|
||||
RolloutLegacyOnly RolloutState = "legacy_only"
|
||||
RolloutDualValidate RolloutState = "dual_validate"
|
||||
RolloutUnifiedActive RolloutState = "unified_active"
|
||||
RolloutUnifiedStable RolloutState = "unified_stable"
|
||||
RolloutUnifiedOnly RolloutState = "unified_only"
|
||||
)
|
||||
|
||||
const rolloutAnnotation = "dws.output.rollout"
|
||||
|
||||
func ParseRolloutState(raw string) (RolloutState, error) {
|
||||
state := RolloutState(strings.TrimSpace(raw))
|
||||
switch state {
|
||||
case RolloutLegacyOnly, RolloutDualValidate, RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
|
||||
return state, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output rollout state %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRolloutTransition defines the release order for rollout tooling. It
|
||||
// is not an end-user capability. CI/ledger enforcement is wired separately.
|
||||
func ValidateRolloutTransition(from, to RolloutState, rollback bool) error {
|
||||
if _, err := ParseRolloutState(string(from)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ParseRolloutState(string(to)); err != nil {
|
||||
return err
|
||||
}
|
||||
fromRank, toRank := rolloutRank(from), rolloutRank(to)
|
||||
if fromRank == toRank || toRank == fromRank+1 {
|
||||
return nil
|
||||
}
|
||||
if rollback && toRank < fromRank {
|
||||
return nil
|
||||
}
|
||||
if toRank < fromRank {
|
||||
return fmt.Errorf("output rollout transition %s -> %s is a rollback and requires explicit rollback approval", from, to)
|
||||
}
|
||||
return fmt.Errorf("output rollout transition %s -> %s skips intermediate states", from, to)
|
||||
}
|
||||
|
||||
func rolloutRank(state RolloutState) int {
|
||||
switch state {
|
||||
case RolloutLegacyOnly:
|
||||
return 0
|
||||
case RolloutDualValidate:
|
||||
return 1
|
||||
case RolloutUnifiedActive:
|
||||
return 2
|
||||
case RolloutUnifiedStable:
|
||||
return 3
|
||||
case RolloutUnifiedOnly:
|
||||
return 4
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func SetCommandRollout(cmd *cobra.Command, state RolloutState) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if _, err := ParseRolloutState(string(state)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[rolloutAnnotation] = string(state)
|
||||
}
|
||||
|
||||
// CommandRollout fails closed. Merely linking the unified result framework
|
||||
// cannot change an undeclared command's wire contract.
|
||||
func CommandRollout(cmd *cobra.Command) RolloutState {
|
||||
if cmd != nil && cmd.Annotations != nil {
|
||||
if state, err := ParseRolloutState(cmd.Annotations[rolloutAnnotation]); err == nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return RolloutLegacyOnly
|
||||
}
|
||||
|
||||
// ActiveContract is the only contract exposed by a command in this release.
|
||||
func ActiveContract(cmd *cobra.Command) ContractMode {
|
||||
switch CommandRollout(cmd) {
|
||||
case RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
|
||||
return ContractUnified
|
||||
default:
|
||||
return ContractLegacy
|
||||
}
|
||||
}
|
||||
|
||||
func UsesUnifiedResult(cmd *cobra.Command) bool { return ActiveContract(cmd) == ContractUnified }
|
||||
|
||||
// ValidateUnifiedFormat is retained for compatibility. All commands normalize an
|
||||
// unknown presentation value to their fallback and emit a diagnostic warning.
|
||||
func ValidateUnifiedFormat(cmd *cobra.Command) error {
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user