feat(tester): add integration-first journey execution
This commit is contained in:
@@ -88,6 +88,23 @@ APK/IPA,为本轮创建隔离 worktree,在 Android 和 iOS tester 节点安
|
||||
|
||||
### 客户端用户旅程
|
||||
|
||||
测试用例支持两种顶层类型:`test_type: integration` 和 `test_type: journey`。集成套件
|
||||
至少包括 `api-contract`、`authorization`、`build-contract`;客户端套件仍是
|
||||
`client-smoke`、`client-regression`、`safety`。没有显式范围时默认不选择 integration。
|
||||
|
||||
执行顺序固定为 `test-preflight -> integration -> journey`。preflight 状态为 blocked 或
|
||||
environment-fail 时不创建客户端 session;integration 失败时依赖 journey 为 `not-run`。
|
||||
服务不可达是 environment-fail,契约断言错误是 integration fail,正确账号登录失败仍是
|
||||
journey fail。`tester run`/bundle 必须传入 caller 提供的 matrix:
|
||||
|
||||
```bash
|
||||
<skill-dir>/tester run --preflight-config /abs/preflight.yaml \
|
||||
--artifact android=/abs/app.apk --artifact ios=/abs/app.ipa
|
||||
```
|
||||
|
||||
没有真实 deployer、Mac 或设备时,只能使用 test-preflight 的 command-contract mock fixture
|
||||
验证协议,不能把 mock 结果写成真实运行成功。
|
||||
|
||||
客户端 E2E 用例使用 `client_suite: client-smoke | client-regression | safety`,并明确
|
||||
`journey`、`fixture`、`session`、`cleanup`、`platform`。标准生命周期是:
|
||||
|
||||
|
||||
@@ -148,6 +148,27 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
||||
|
||||
### 客户端用户旅程标准
|
||||
|
||||
### 双层测试与执行顺序
|
||||
|
||||
用例顶层类型是 `test_type: integration | journey`。旧目录没有该字段时保持兼容:已有
|
||||
`client_suite`/`journey` 视为 journey,已有 `integration_suite` 视为 integration。集成
|
||||
套件至少包括 `api-contract`、`authorization`、`build-contract`;旅程套件仍固定为
|
||||
`client-smoke`、`client-regression`、`safety`。集成用例不需要客户端 session、platform
|
||||
或 journey 字段,不能被客户端套件选中。
|
||||
|
||||
一次运行严格遵守:
|
||||
|
||||
```text
|
||||
test-preflight (blocked/environment-fail -> 不创建 session)
|
||||
-> integration
|
||||
-> journey (integration fail -> not-run,不创建 session)
|
||||
```
|
||||
|
||||
`test-preflight` 的报告是环境证据,不计入 testcase 总数。服务不可达记
|
||||
`environment-fail`;服务已达但契约断言错误记 integration `fail`;正确测试账号登录失败
|
||||
仍记 journey `fail`。tester 的 `run`/bundle 入口必须接收 caller 提供的 preflight matrix,
|
||||
没有 matrix 不得绕过闸门或伪造成功。
|
||||
|
||||
声明 `client_suite`(迁移期也接受 `suite`)的用例采用固定的三套客户端集合:
|
||||
`client-smoke`、`client-regression`、`safety`。前两者分别覆盖最短只读主路径和隔离测试
|
||||
环境中的普通用户回归;`safety` 覆盖危险/权限边界场景,必须显式选择和授权,不能被
|
||||
|
||||
@@ -41,6 +41,8 @@ catalog(整个测试集)
|
||||
| `expected` | 怎么算过:`{kind, value}` 列表 | pytest `assert`;Robot `Should …` | 不是步骤 |
|
||||
| `run_together` | 必须成组执行的用例(有序、可选 continue_on_fail) | 场景组(scenario) | 不是 suite,不是 tag |
|
||||
| `client_suite` | 客户端用户旅程套件:`client-smoke` / `client-regression` / `safety` | mobile/browser E2E suite | 不是 `category`,也不是通用 tag;迁移期可读 `suite` 别名 |
|
||||
| `test_type` | 顶层测试类型:`journey` 或 `integration` | E2E vs service/contract test | 不是 driver;旧目录可由既有字段推断 |
|
||||
| `integration_suite` | 集成套件:`api-contract` / `authorization` / `build-contract` | API/contract test suite | 不是客户端 `client_suite`;迁移期可读 `suite` 别名 |
|
||||
| `journey` | 用户目标、入口、操作、UI 断言、最终状态和失败继续策略 | Cucumber scenario / Robot task | 不是 API contract |
|
||||
| `fixture` | 旅程开始前声明的测试账号与隔离数据 | pytest fixture | 不是运行中临时猜出的数据 |
|
||||
| `cleanup` | 本轮数据的 teardown 契约;缺失会在 preflight 阻断 | pytest teardown / Robot teardown | 不是测试通过后的可选备注 |
|
||||
|
||||
@@ -9,6 +9,11 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
|
||||
|
||||
## 0. 建立测试矩阵并完成全量 preflight
|
||||
|
||||
`test-preflight` 是 integration 和 journey 的共同硬闸。tester 必须先调用其 `verify`
|
||||
或 `prepare`,保存独立的 preflight YAML/CSV;状态为 `blocked` 或 `environment-fail`
|
||||
时不创建 browser/mobile session,所有尚未执行的 testcase 记 `not-run`。preflight 证据
|
||||
不算 testcase,也不进入 testcase CSV/YAML 的总数。
|
||||
|
||||
测试不是“找到一个能跑的设备就开始”。每轮先建立一份测试矩阵;移动端每个目标产物
|
||||
必须单独占一行:
|
||||
|
||||
@@ -76,6 +81,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
||||
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
|
||||
[--max-risk <risk>] [--driver script|browser|mobile] \
|
||||
[--automation automated|assisted|manual|any] \
|
||||
[--test-type integration|journey] [--suite <client-or-integration-suite>] \
|
||||
[--platform android|ios]... \
|
||||
[--artifact android=<path-or-url>] [--artifact ios=<path-or-url>] \
|
||||
[--binding android=tester.android] [--binding ios=tester.ios]
|
||||
@@ -90,6 +96,15 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-smoke
|
||||
`client-smoke`、`client-regression`、`safety` 是 `client_suite` 的固定值,不替换既有
|
||||
`tag=smoke|full|slow`。`safety` 不参与未指定范围的默认选择。
|
||||
|
||||
集成测试显式选择:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/catalog.py select --root <root> --test-type integration
|
||||
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite api-contract
|
||||
```
|
||||
|
||||
未指定 `--test-type`/`--suite` 时,默认选择不会包含 `test_type=integration`。
|
||||
|
||||
选中任一 `driver=mobile` 用例时,`--platform`、每个平台的 `--artifact` 和对应
|
||||
`tester` binding 是硬性参数;`catalog.py select` 会检查产物文件/下载地址和
|
||||
`.pouch/agent.yaml` 中的 binding。缺任何一项直接返回非零并打印 `preflight: blocked`,
|
||||
@@ -106,7 +121,18 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-smoke
|
||||
|
||||
无命中:停下说明筛选条件,不要扩大范围。
|
||||
|
||||
## 2. 运行前闸
|
||||
## 2. 双层执行
|
||||
|
||||
preflight 通过后,先按 catalog 顺序执行 `integration`,再按客户端套件顺序执行
|
||||
`journey`。集成服务不可达是 `environment-fail`;服务已达但 API/权限/数据契约断言错误
|
||||
是 `fail`。任何 integration `fail` 都阻断依赖的 journey,并把它们记为 `not-run`;此时
|
||||
不得创建客户端 session。正确测试账号登录失败发生在客户端已启动后,仍是产品 `fail`。
|
||||
|
||||
运行器可复用 `scripts/two_tier.py` 的 `run_integration_first`,它只接收外部 driver
|
||||
回调,不实现 API、浏览器或移动执行器;mock fixture 只能验证调用顺序和失败传播,不能
|
||||
冒充真实部署、Mac 或设备成功。
|
||||
|
||||
## 3. 运行前闸
|
||||
|
||||
- `HOST` 未设(环境变量 / `local.yaml` 都没有):全部停,environment-fail。
|
||||
- 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。
|
||||
@@ -123,7 +149,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-smoke
|
||||
- 探测不可达或命中 `stop_if`:在 preflight 阶段记录对应分类/平台的不可用项并停止整轮;
|
||||
先返回用户处理,不创建后续测试 session。只有所有本轮目标环境通过,才进入用例执行。
|
||||
|
||||
## 2.1 移动端平台环境闸门与用户引导
|
||||
## 3.1 移动端平台环境闸门与用户引导
|
||||
|
||||
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
|
||||
|
||||
@@ -184,7 +210,7 @@ Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台
|
||||
|
||||
环境引导只负责让用户补齐前置条件;除 catalog 已明确授权的 session 初始化外,不自动安装 SDK、配对真机、修改系统安全设置或上传产物。
|
||||
|
||||
## 3. Session
|
||||
## 4. Session
|
||||
|
||||
看命中条用到的 `session` 键:
|
||||
|
||||
@@ -193,7 +219,7 @@ Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台
|
||||
- `reuse: run`:整轮结束再关。`persist`:不关。
|
||||
- 中途 session 挂了:后续同 driver 标 environment-fail,不要标产品失败。
|
||||
|
||||
## 4. 执行
|
||||
## 5. 执行
|
||||
|
||||
按 select 返回的顺序,一条一条来。select 按 `catalog.yaml` 的 `categories` 顺序,同一分类内按该 yaml 列表顺序(不是文件名字母序)。默认失败即停(产品 `fail` 或探测到的不安全状态)。用户说继续 / keep-going 才往后跑。
|
||||
|
||||
@@ -225,7 +251,7 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
|
||||
|
||||
挂到已有 session,执行 `how.steps`,对照 `expected`。必须留下可见文本、URL 或交互证据。
|
||||
|
||||
## 5. 记录与报告
|
||||
## 6. 记录与报告
|
||||
|
||||
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ runs/<run-id>.yaml 每次运行快照(执行源);run-id 必须唯一
|
||||
runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成
|
||||
```
|
||||
|
||||
客户端旅程在上述目录中仍使用同一套 cases/runs 和五态报告,不另造成绩单。启用旅程
|
||||
契约的用例声明 `client_suite`(兼容迁移期别名 `suite`);catalog 可用
|
||||
`client_suites` 显式列出三个标准套件:`client-smoke`、`client-regression`、`safety`。
|
||||
`category` 仍是文件/执行顺序容器,不被套件字段替代。
|
||||
两类用例共用同一套 cases/runs 和五态报告,不另造成绩单。新目录显式声明顶层
|
||||
`test_type: journey | integration`;旧目录没有该字段时按已有 `client_suite`/`journey`
|
||||
或 `integration_suite` 推断。`category` 仍是文件/执行顺序容器,不被套件字段替代。
|
||||
|
||||
## catalog.yaml
|
||||
|
||||
@@ -35,6 +34,7 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
|
||||
| `run_together` | 可选。成组执行:`[{ids: [TC-…, TC-…], continue_on_fail: true}]`。点到组内任一条,`select` 会按 `ids` 顺序把整组扩进命中列表。`continue_on_fail: true` 时组内前一条产品 fail 仍跑后一条(返回/收尾)。一个 id 只能属于一组。 |
|
||||
| `status_probe` | 可选。顶层默认值,一个**分类级前置条件**:`{url, stop_if: {字段: [命中值]}}`。运行前请求 `url`,响应命中任一 `stop_if` 条件即视为前置不满足。分类项可用同名键覆盖:缺省继承顶层、`false` 关闭、mapping 整体替换。命中或不可达只影响用它的分类,不是产品失败。业界对应 pytest 的 session fixture / Robot 的 Suite Setup。`select` 会按分类返回解析后的生效值,`url` 已用 `vars` 展开 |
|
||||
| `client_suites` | 可选的标准套件声明;出现时必须完整包含 `client-smoke`、`client-regression`、`safety`。缺省运行仍按 `tag` 兼容旧目录 |
|
||||
| `integration_suites` | 可选的集成套件声明;标准值为 `api-contract`、`authorization`、`build-contract` |
|
||||
|
||||
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。模板中的 `HOST` 默认是
|
||||
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
|
||||
@@ -76,6 +76,7 @@ terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本
|
||||
| 字段 | 必填 | 含义 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 匹配 `id_pattern` |
|
||||
| `test_type` | 新用例是 | `journey` 或 `integration`;缺省兼容旧目录并按字段推断 |
|
||||
| `category` | 是 | 必须等于文件声明的 category |
|
||||
| `title` | 是 | 短名 |
|
||||
| `what` | 是 | 一句可观察的「测什么」 |
|
||||
@@ -94,6 +95,18 @@ terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本
|
||||
| `teardown` | 否 | 跑完回退 |
|
||||
| `notes` | 否 | 备注 |
|
||||
|
||||
### 集成测试用例契约
|
||||
|
||||
`test_type: integration` 的用例不创建 browser/mobile session,也不能声明 `platform` 或
|
||||
客户端 `journey`。必须有 `integration_suite`(迁移期可用 `suite`)且值为
|
||||
`api-contract`、`authorization` 或 `build-contract`;`fixture` 必须是非空 mapping;
|
||||
`request`、`input`、`call`、`integration` 之一或旧式 `how` 用于描述调用;`expected` 是
|
||||
协议/数据断言。`mutating`/`destructive` 集成用例必须提供 `cleanup.steps` 或
|
||||
`cleanup.run`(只读探针可用 `required: false` + `reason`)。
|
||||
|
||||
选择 `--suite api-contract` 或 `--test-type integration` 才会进入集成阶段;无显式类型
|
||||
时的默认选择不会包含 integration。
|
||||
|
||||
### 客户端旅程用例契约
|
||||
|
||||
声明 `client_suite` 后,以下字段都必须存在;`catalog.py validate` 和 `select` 使用同一
|
||||
|
||||
@@ -18,7 +18,15 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from journey import CLIENT_SUITES, client_suite, contract_errors, preflight as journey_preflight
|
||||
from journey import (
|
||||
CLIENT_SUITES,
|
||||
INTEGRATION_SUITES,
|
||||
TEST_TYPES,
|
||||
contract_errors,
|
||||
preflight as journey_preflight,
|
||||
suite as case_suite,
|
||||
test_type,
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
@@ -370,7 +378,7 @@ def cmd_list(root: Path) -> int:
|
||||
pending = [c for c in cases if c.get("lifecycle") == "pending"]
|
||||
print(f"## 测试目录 active {len(active)} / pending {len(pending)} / retired {len(retired)}")
|
||||
print()
|
||||
headers = ["分类", "标题", "条数", "smoke"] + risks + list(DRIVERS)
|
||||
headers = ["分类", "标题", "条数", "journey", "integration", "smoke"] + risks + list(DRIVERS)
|
||||
print("| " + " | ".join(headers) + " |")
|
||||
print("| " + " | ".join("---" for _ in headers) + " |")
|
||||
|
||||
@@ -388,7 +396,14 @@ def cmd_list(root: Path) -> int:
|
||||
title = (cats_meta.get(cat) or {}).get("title") or (
|
||||
group[0].get("_file_title") if group else ""
|
||||
)
|
||||
row = [cat, str(title), str(len(group)), str(sum(1 for c in group if c.get("tag") == "smoke"))]
|
||||
row = [
|
||||
cat,
|
||||
str(title),
|
||||
str(len(group)),
|
||||
str(sum(1 for c in group if test_type(c) == "journey")),
|
||||
str(sum(1 for c in group if test_type(c) == "integration")),
|
||||
str(sum(1 for c in group if c.get("tag") == "smoke")),
|
||||
]
|
||||
for risk in risks:
|
||||
row.append(str(sum(1 for c in group if c.get("risk") == risk)))
|
||||
for driver in DRIVERS:
|
||||
@@ -783,7 +798,13 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
continue
|
||||
if args.tag and case.get("tag") != args.tag:
|
||||
continue
|
||||
if args.suite and client_suite(case) != args.suite:
|
||||
if args.test_type and test_type(case) != args.test_type:
|
||||
continue
|
||||
if args.suite and case_suite(case) != args.suite:
|
||||
continue
|
||||
# A mixed catalog keeps legacy cases runnable, but the implicit
|
||||
# default is the client journey surface. Integration is opt-in.
|
||||
if not args.test_type and not args.suite and test_type(case) == "integration":
|
||||
continue
|
||||
if args.driver and case.get("driver") != args.driver:
|
||||
continue
|
||||
@@ -814,6 +835,8 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
|
||||
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
|
||||
|
||||
# The historical key is kept in the JSON, but the gate now validates both
|
||||
# test types before any caller can start a phase.
|
||||
journey_gate = journey_preflight(selected, sessions=meta.get("sessions") or {})
|
||||
if journey_gate["status"] == "blocked":
|
||||
print(json.dumps({"preflight": journey_gate, "journey_preflight": journey_gate}, ensure_ascii=False, indent=2))
|
||||
@@ -850,6 +873,10 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
"artifacts": meta.get("artifacts") or {},
|
||||
"preflight": preflight,
|
||||
"journey_preflight": journey_gate,
|
||||
"test_types": {
|
||||
"journey": sum(1 for case in selected if test_type(case) == "journey"),
|
||||
"integration": sum(1 for case in selected if test_type(case) == "integration"),
|
||||
},
|
||||
"skipped": skipped,
|
||||
"run_together": together,
|
||||
"status_probe": status_probes,
|
||||
@@ -943,6 +970,17 @@ def validate(root: Path) -> list[str]:
|
||||
missing_suites = sorted(set(CLIENT_SUITES) - set(client_suites))
|
||||
if missing_suites:
|
||||
errors.append("catalog.client_suites 缺少标准套件: " + ", ".join(missing_suites))
|
||||
integration_suites = meta.get("integration_suites")
|
||||
if integration_suites is not None:
|
||||
if not isinstance(integration_suites, dict):
|
||||
errors.append("catalog.integration_suites 必须是 mapping")
|
||||
else:
|
||||
unknown = sorted(set(integration_suites) - set(INTEGRATION_SUITES))
|
||||
if unknown:
|
||||
errors.append("catalog.integration_suites 含非法套件: " + ", ".join(unknown))
|
||||
missing_suites = sorted(set(INTEGRATION_SUITES) - set(integration_suites))
|
||||
if missing_suites:
|
||||
errors.append("catalog.integration_suites 缺少标准套件: " + ", ".join(missing_suites))
|
||||
artifacts = meta.get("artifacts")
|
||||
if artifacts is not None:
|
||||
if not isinstance(artifacts, dict):
|
||||
@@ -1297,8 +1335,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_sel.add_argument("--category")
|
||||
p_sel.add_argument("--id", action="append", dest="ids", default=[])
|
||||
p_sel.add_argument("--tag", choices=TAGS)
|
||||
p_sel.add_argument("--suite", "--client-suite", dest="suite", choices=CLIENT_SUITES,
|
||||
help="客户端旅程套件")
|
||||
p_sel.add_argument(
|
||||
"--suite", "--client-suite", dest="suite",
|
||||
choices=[*CLIENT_SUITES, *INTEGRATION_SUITES],
|
||||
help="客户端或集成测试套件;不传时默认不选 integration",
|
||||
)
|
||||
p_sel.add_argument(
|
||||
"--test-type", choices=TEST_TYPES, default=None,
|
||||
help="按顶层 test_type 筛选;integration 必须显式选择",
|
||||
)
|
||||
p_sel.add_argument("--max-risk")
|
||||
p_sel.add_argument("--driver", choices=DRIVERS)
|
||||
p_sel.add_argument(
|
||||
|
||||
@@ -13,6 +13,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from journey import suite as case_suite, test_type
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -22,6 +24,8 @@ except ImportError: # pragma: no cover
|
||||
FIELDS = (
|
||||
"category",
|
||||
"id",
|
||||
"test_type",
|
||||
"suite",
|
||||
"client_suite",
|
||||
"platform",
|
||||
"maps_to",
|
||||
@@ -158,11 +162,16 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
||||
writer.writeheader()
|
||||
for case in cases:
|
||||
cid = str(case.get("id") or "")
|
||||
client_suite_value = case.get("client_suite") or (
|
||||
case.get("suite") if test_type(case) == "journey" else ""
|
||||
)
|
||||
writer.writerow(
|
||||
{
|
||||
"category": case.get("category") or "",
|
||||
"id": cid,
|
||||
"client_suite": case.get("client_suite") or case.get("suite") or "",
|
||||
"test_type": test_type(case),
|
||||
"suite": case_suite(case),
|
||||
"client_suite": client_suite_value or "",
|
||||
"platform": one_line(case.get("platform")),
|
||||
"maps_to": case.get("maps_to") or "",
|
||||
"title": case.get("title") or "",
|
||||
|
||||
@@ -18,6 +18,7 @@ except ImportError: # pragma: no cover
|
||||
raise SystemExit(2)
|
||||
|
||||
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
|
||||
from journey import suite as case_suite, test_type
|
||||
|
||||
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
||||
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
@@ -25,6 +26,8 @@ EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
|
||||
EXECUTED_NO = frozenset({"skipped", "not-run"})
|
||||
FIELDS = (
|
||||
"id",
|
||||
"test_type",
|
||||
"suite",
|
||||
"client_suite",
|
||||
"platform",
|
||||
"result",
|
||||
@@ -131,10 +134,15 @@ def export_run_csv(
|
||||
cid = str(item["id"])
|
||||
result = str(item.get("result") or "")
|
||||
case = cases.get(cid) or {}
|
||||
client_suite_value = case.get("client_suite") or (
|
||||
case.get("suite") if test_type(case) == "journey" else ""
|
||||
)
|
||||
writer.writerow(
|
||||
{
|
||||
"id": cid,
|
||||
"client_suite": case.get("client_suite") or case.get("suite") or "",
|
||||
"test_type": test_type(case),
|
||||
"suite": case_suite(case),
|
||||
"client_suite": client_suite_value or "",
|
||||
"platform": one_line(case.get("platform")),
|
||||
"result": result,
|
||||
"executed": executed_cell(result),
|
||||
|
||||
@@ -13,7 +13,9 @@ from __future__ import annotations
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
TEST_TYPES = ("journey", "integration")
|
||||
CLIENT_SUITES = ("client-smoke", "client-regression", "safety")
|
||||
INTEGRATION_SUITES = ("api-contract", "authorization", "build-contract")
|
||||
PLATFORMS = ("android", "ios")
|
||||
FAILURE_POLICIES = ("stop", "continue")
|
||||
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
||||
@@ -28,6 +30,33 @@ def client_suite(case: Mapping[str, Any]) -> str:
|
||||
return str(value or alias or "")
|
||||
|
||||
|
||||
def integration_suite(case: Mapping[str, Any]) -> str:
|
||||
"""Return the integration suite, accepting ``suite`` during migration."""
|
||||
value = case.get("integration_suite")
|
||||
alias = case.get("suite")
|
||||
if value and alias and str(value) != str(alias):
|
||||
return ""
|
||||
return str(value or alias or "")
|
||||
|
||||
|
||||
def test_type(case: Mapping[str, Any]) -> str:
|
||||
"""Return an explicit type or infer one for an old catalog entry."""
|
||||
declared = str(case.get("test_type") or "").strip().lower()
|
||||
if declared:
|
||||
return declared
|
||||
if client_suite(case) or case.get("journey") is not None:
|
||||
return "journey"
|
||||
if integration_suite(case):
|
||||
return "integration"
|
||||
return ""
|
||||
|
||||
|
||||
def suite(case: Mapping[str, Any]) -> str:
|
||||
"""Return the report/selection suite for either test type."""
|
||||
kind = test_type(case)
|
||||
return client_suite(case) if kind == "journey" else integration_suite(case)
|
||||
|
||||
|
||||
def normalize_platforms(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
@@ -57,16 +86,23 @@ def contract_errors(case: Mapping[str, Any], sessions: Mapping[str, Any] | None
|
||||
schema. Once opted in, all lifecycle inputs are explicit so a missing
|
||||
fixture/cleanup can stop the run before any case result is fabricated.
|
||||
"""
|
||||
suite = client_suite(case)
|
||||
journey = case.get("journey")
|
||||
if not suite and journey is None:
|
||||
kind = test_type(case)
|
||||
declared_kind = str(case.get("test_type") or "").strip().lower()
|
||||
if declared_kind and declared_kind not in TEST_TYPES:
|
||||
return [f"{case.get('id') or '?'}: test_type 必须是 journey 或 integration"]
|
||||
if not kind:
|
||||
return []
|
||||
if kind == "integration":
|
||||
return integration_contract_errors(case)
|
||||
|
||||
suite_name = client_suite(case)
|
||||
journey = case.get("journey")
|
||||
errors: list[str] = []
|
||||
cid = str(case.get("id") or "?")
|
||||
where = f"{cid}: client journey"
|
||||
if case.get("client_suite") and case.get("suite") and str(case["client_suite"]) != str(case["suite"]):
|
||||
errors.append(f"{where}: client_suite 与 suite 不一致")
|
||||
if suite not in CLIENT_SUITES:
|
||||
if suite_name not in CLIENT_SUITES:
|
||||
errors.append(f"{where}: client_suite 必须是 {', '.join(CLIENT_SUITES)}")
|
||||
|
||||
if not isinstance(journey, Mapping):
|
||||
@@ -116,11 +152,69 @@ def contract_errors(case: Mapping[str, Any], sessions: Mapping[str, Any] | None
|
||||
errors.append(f"{where}: session 必须声明可复用客户端会话")
|
||||
elif sessions is not None and session not in sessions:
|
||||
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
|
||||
if suite == "safety" and str(case.get("tag") or "") == "smoke":
|
||||
if suite_name == "safety" and str(case.get("tag") or "") == "smoke":
|
||||
errors.append(f"{where}: safety 不得标记为 smoke")
|
||||
return errors
|
||||
|
||||
|
||||
def integration_contract_errors(case: Mapping[str, Any]) -> list[str]:
|
||||
"""Validate the non-client half of the tester catalog.
|
||||
|
||||
Integration cases deliberately do not require a browser/mobile session. A
|
||||
``how`` block is the legacy call declaration; newer catalogs may use
|
||||
``request``/``input``/``call`` or an ``integration`` mapping as well.
|
||||
``expected`` remains the common protocol/data assertion list.
|
||||
"""
|
||||
cid = str(case.get("id") or "?")
|
||||
where = f"{cid}: integration"
|
||||
errors: list[str] = []
|
||||
value = case.get("integration_suite")
|
||||
alias = case.get("suite")
|
||||
if value and alias and str(value) != str(alias):
|
||||
errors.append(f"{where}: integration_suite 与 suite 不一致")
|
||||
suite_name = integration_suite(case)
|
||||
if suite_name not in INTEGRATION_SUITES:
|
||||
errors.append(
|
||||
f"{where}: integration_suite 必须是 {', '.join(INTEGRATION_SUITES)}"
|
||||
)
|
||||
|
||||
fixture = case.get("fixture")
|
||||
if not isinstance(fixture, Mapping) or not fixture:
|
||||
errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明")
|
||||
|
||||
declared_call = any(
|
||||
key in case and case.get(key) not in (None, "", [], {})
|
||||
for key in ("request", "input", "call", "integration")
|
||||
)
|
||||
how = case.get("how")
|
||||
if not declared_call and not isinstance(how, Mapping):
|
||||
errors.append(f"{where}: 必须声明 request/input/call/integration 或 how")
|
||||
|
||||
# Integration writes need an explicit rollback/cleanup contract. Read-only
|
||||
# probes may state why cleanup is unnecessary, matching test-preflight.
|
||||
risk = str(case.get("risk") or "")
|
||||
cleanup = case.get("cleanup")
|
||||
cleanup_steps = isinstance(cleanup, Mapping) and (
|
||||
bool(cleanup.get("steps")) or bool(str(cleanup.get("run") or "").strip())
|
||||
)
|
||||
cleanup_noop = isinstance(cleanup, Mapping) and cleanup.get("required") is False and bool(
|
||||
str(cleanup.get("reason") or "").strip()
|
||||
)
|
||||
if risk != "readonly" and not (cleanup_steps or cleanup_noop):
|
||||
errors.append(f"{where}: 写入型 integration 必须声明 cleanup/rollback steps/run")
|
||||
elif cleanup is not None and not (cleanup_steps or cleanup_noop):
|
||||
errors.append(f"{where}: cleanup 必须有 steps/run 或 required=false + reason")
|
||||
|
||||
forbidden = []
|
||||
if case.get("client_suite") or case.get("journey") is not None:
|
||||
forbidden.append("client_suite/journey")
|
||||
if case.get("session") or case.get("platform"):
|
||||
forbidden.append("session/platform")
|
||||
if forbidden:
|
||||
errors.append(f"{where}: integration 不应声明 " + ", ".join(forbidden))
|
||||
return errors
|
||||
|
||||
|
||||
def preflight(cases: Iterable[Mapping[str, Any]], sessions: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Return a contract preflight result; no per-case result is created on block."""
|
||||
cases = list(cases)
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
@@ -33,6 +34,7 @@ from typing import Any
|
||||
|
||||
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
PLATFORMS = {"android", "ios"}
|
||||
PREFLIGHT_ROOT = Path(__file__).resolve().parents[2] / "test-preflight" / "scripts" / "preflight.py"
|
||||
|
||||
|
||||
class EnvironmentFailure(RuntimeError):
|
||||
@@ -388,6 +390,33 @@ def write_run(root: Path, record: dict[str, Any]) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def run_test_preflight(
|
||||
config_path: Path,
|
||||
*,
|
||||
mode: str = "verify",
|
||||
yaml_path: Path | None = None,
|
||||
csv_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the shared preflight contract before any client session work."""
|
||||
if mode not in {"verify", "prepare"}:
|
||||
raise ArtifactFailure("preflight mode 只能是 verify 或 prepare")
|
||||
if not PREFLIGHT_ROOT.is_file():
|
||||
raise EnvironmentFailure(f"找不到 test-preflight skill: {PREFLIGHT_ROOT}")
|
||||
spec = importlib.util.spec_from_file_location("pouch_test_preflight", PREFLIGHT_ROOT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise EnvironmentFailure("无法加载 test-preflight skill")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
try:
|
||||
config = module.load_config(config_path)
|
||||
evidence = module.run(config, mode)
|
||||
yaml_path = yaml_path or config_path.with_name("preflight.yaml")
|
||||
csv_path = csv_path or config_path.with_name("preflight.csv")
|
||||
return module.write_evidence(evidence, yaml_path, csv_path)
|
||||
except (OSError, ValueError, module.yaml.YAMLError) as exc:
|
||||
return {"status": "blocked", "error": str(exc), "yaml": str(yaml_path or ""), "csv": str(csv_path or "")}
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
requested = platforms(args.platform)
|
||||
context = RuntimeContext(Path(args.root).resolve())
|
||||
@@ -417,7 +446,7 @@ def execute(args: argparse.Namespace) -> int:
|
||||
artifact_dir = Path(tempfile.mkdtemp(prefix="pouch-tester-artifacts-"))
|
||||
record["artifact_dir"] = str(artifact_dir)
|
||||
expected = catalog_identities(context.root)
|
||||
prepared: dict[str, tuple[dict[str, Any], dict[str, str], str]] = {}
|
||||
prepared: dict[str, tuple[dict[str, Any], dict[str, str] | None, str]] = {}
|
||||
failures: dict[str, str] = {}
|
||||
for platform in requested:
|
||||
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
|
||||
@@ -429,22 +458,65 @@ def execute(args: argparse.Namespace) -> int:
|
||||
wanted = expected.get(platform)
|
||||
if wanted and identity != wanted:
|
||||
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={wanted} actual={identity}")
|
||||
node = android_preflight(context) if platform == "android" else ios_preflight(context)
|
||||
check_network(context, node)
|
||||
result.update({"identity": identity, "node_preflight": node, "status": "preflight-passed"})
|
||||
prepared[platform] = (result, node, identity)
|
||||
result.update({"identity": identity, "status": "artifact-ready"})
|
||||
prepared[platform] = (result, None, identity)
|
||||
except (EnvironmentFailure, ArtifactFailure) as exc:
|
||||
failures[platform] = str(exc)
|
||||
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
|
||||
|
||||
# This is the hard gate shared by integration and journey callers.
|
||||
# There is no safe implicit matrix: without a caller-supplied config we
|
||||
# cannot prove HOST, fixtures, tester bindings, or node reachability.
|
||||
preflight_config = Path(args.preflight_config).expanduser() if args.preflight_config else None
|
||||
if not failures:
|
||||
if preflight_config is None:
|
||||
preflight_result = {
|
||||
"status": "blocked",
|
||||
"error": "缺少 --preflight-config;不能跳过 test-preflight",
|
||||
}
|
||||
else:
|
||||
preflight_result = run_test_preflight(
|
||||
preflight_config,
|
||||
mode=args.preflight_mode,
|
||||
yaml_path=Path(args.preflight_yaml).expanduser() if args.preflight_yaml else None,
|
||||
csv_path=Path(args.preflight_csv).expanduser() if args.preflight_csv else None,
|
||||
)
|
||||
record["preflight"] = preflight_result
|
||||
if preflight_result.get("status") != "ready":
|
||||
failures["preflight"] = str(preflight_result.get("error") or preflight_result.get("status"))
|
||||
|
||||
if not failures and args.preflight_mode == "prepare":
|
||||
# test-preflight prepare owns platform probing, installation and
|
||||
# launch; do not install/start the same artifact a second time.
|
||||
for result, _, _ in prepared.values():
|
||||
result.update({"status": "preflight-prepared"})
|
||||
elif not failures:
|
||||
for platform in requested:
|
||||
result, _, identity = prepared[platform]
|
||||
try:
|
||||
node = android_preflight(context) if platform == "android" else ios_preflight(context)
|
||||
check_network(context, node)
|
||||
result.update({"node_preflight": node, "status": "preflight-passed"})
|
||||
prepared[platform] = (result, node, identity)
|
||||
except EnvironmentFailure as exc:
|
||||
failures[platform] = str(exc)
|
||||
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
|
||||
if failures and not args.partial:
|
||||
for platform in requested:
|
||||
if platform not in failures:
|
||||
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
|
||||
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
|
||||
overall = record.get("preflight", {}).get("status") if "preflight" in record else "environment-fail"
|
||||
record.update({"status": overall if overall in {"blocked", "environment-fail"} else "environment-fail", "failure_class": "test-preflight" if "preflight" in record else "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
|
||||
else:
|
||||
for platform, (result, node, identity) in prepared.items():
|
||||
try:
|
||||
installed = install_android(node, Path(result["artifact"]["path"]), identity, context) if platform == "android" else install_ios(node, Path(result["artifact"]["path"]), identity, context)
|
||||
installed = (
|
||||
{"delegated": "test-preflight.prepare"}
|
||||
if args.preflight_mode == "prepare"
|
||||
else install_android(node, Path(result["artifact"]["path"]), identity, context)
|
||||
if platform == "android"
|
||||
else install_ios(node, Path(result["artifact"]["path"]), identity, context)
|
||||
)
|
||||
result.update({"status": "ready", "install": installed, "failure_class": None})
|
||||
except EnvironmentFailure as exc:
|
||||
failures[platform] = str(exc)
|
||||
@@ -476,6 +548,10 @@ def parser() -> argparse.ArgumentParser:
|
||||
run.add_argument("--root", default=".")
|
||||
run.add_argument("--worktree-root", default="")
|
||||
run.add_argument("--binding", default="")
|
||||
run.add_argument("--preflight-config", default="", help="test-preflight matrix YAML(必填,不得跳过)")
|
||||
run.add_argument("--preflight-mode", choices=("verify", "prepare"), default="verify")
|
||||
run.add_argument("--preflight-yaml", default="", help="preflight YAML 证据输出路径")
|
||||
run.add_argument("--preflight-csv", default="", help="preflight CSV 证据输出路径")
|
||||
run.set_defaults(func=execute)
|
||||
return root
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared execution policy for tester's integration-first runs.
|
||||
|
||||
This module intentionally has no browser, mobile, deployment, or API driver.
|
||||
Those adapters belong to the caller. It only controls the phase gate and the
|
||||
stable five-state testcase result propagation so a caller cannot create a
|
||||
journey session before preflight and integration have passed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
||||
PHASES = ("preflight", "integration", "journey")
|
||||
|
||||
|
||||
def classify_integration(*, ok: bool, service_reachable: bool = True) -> str:
|
||||
"""Separate a contract assertion failure from an unavailable service."""
|
||||
if not service_reachable:
|
||||
return "environment-fail"
|
||||
return "pass" if ok else "fail"
|
||||
|
||||
|
||||
def _case_id(case: Mapping[str, Any] | str) -> str:
|
||||
if isinstance(case, Mapping):
|
||||
return str(case.get("id") or "")
|
||||
return str(case)
|
||||
|
||||
|
||||
def _not_run(cases: Iterable[Mapping[str, Any] | str], reason: str) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for case in cases:
|
||||
row = {"id": _case_id(case), "result": "not-run", "notes": reason}
|
||||
if isinstance(case, Mapping):
|
||||
row["test_type"] = str(case.get("test_type") or "")
|
||||
row["suite"] = str(
|
||||
case.get("client_suite")
|
||||
or case.get("integration_suite")
|
||||
or case.get("suite")
|
||||
or ""
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def run_integration_first(
|
||||
*,
|
||||
preflight_status: str,
|
||||
integration_cases: Iterable[Mapping[str, Any] | str],
|
||||
journey_cases: Iterable[Mapping[str, Any] | str],
|
||||
execute_integration: Callable[[Mapping[str, Any] | str], str | Mapping[str, Any]],
|
||||
create_journey_session: Callable[[], Any],
|
||||
execute_journey: Callable[[Any, Mapping[str, Any] | str], str | Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Execute integration cases before creating any journey session.
|
||||
|
||||
``preflight_status`` must be the status emitted by test-preflight. A
|
||||
blocked/environment-fail gate records only ``not-run`` testcase rows and
|
||||
never invokes either adapter. An integration failure stops the phase and
|
||||
propagates ``not-run`` to all journey cases.
|
||||
"""
|
||||
integration = list(integration_cases)
|
||||
journey = list(journey_cases)
|
||||
if preflight_status != "ready":
|
||||
reason = f"test-preflight={preflight_status},未创建客户端 session"
|
||||
return {
|
||||
"status": preflight_status,
|
||||
"phase": "preflight",
|
||||
"session_created": False,
|
||||
"results": _not_run(integration, reason) + _not_run(journey, reason),
|
||||
}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
integration_failed = False
|
||||
for index, case in enumerate(integration):
|
||||
if integration_failed:
|
||||
results.extend(_not_run([case], "前置 integration 失败,未轮到执行"))
|
||||
continue
|
||||
outcome = execute_integration(case)
|
||||
row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome}
|
||||
row["id"] = _case_id(case)
|
||||
if isinstance(case, Mapping):
|
||||
row.setdefault("test_type", str(case.get("test_type") or "integration"))
|
||||
row.setdefault("suite", str(case.get("integration_suite") or case.get("suite") or ""))
|
||||
result = str(row.get("result") or "not-run")
|
||||
if result not in RESULTS:
|
||||
raise ValueError(f"未知 integration 结果: {row['id']}={result}")
|
||||
row["result"] = result
|
||||
results.append(row)
|
||||
if result in ("fail", "environment-fail"):
|
||||
integration_failed = True
|
||||
|
||||
if integration_failed:
|
||||
results.extend(_not_run(journey, "integration 失败,依赖的 journey 未执行"))
|
||||
return {
|
||||
"status": "fail" if any(row["result"] == "fail" for row in results) else "environment-fail",
|
||||
"phase": "integration",
|
||||
"session_created": False,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
session = create_journey_session() if journey else None
|
||||
for case in journey:
|
||||
outcome = execute_journey(session, case)
|
||||
row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome}
|
||||
row["id"] = _case_id(case)
|
||||
if isinstance(case, Mapping):
|
||||
row.setdefault("test_type", str(case.get("test_type") or "journey"))
|
||||
row.setdefault("suite", str(case.get("client_suite") or case.get("suite") or ""))
|
||||
result = str(row.get("result") or "not-run")
|
||||
if result not in RESULTS:
|
||||
raise ValueError(f"未知 journey 结果: {row['id']}={result}")
|
||||
row["result"] = result
|
||||
results.append(row)
|
||||
status = "fail" if any(row["result"] == "fail" for row in results) else (
|
||||
"environment-fail" if any(row["result"] == "environment-fail" for row in results) else "pass"
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"phase": "journey" if journey else "integration",
|
||||
"session_created": bool(journey),
|
||||
"results": results,
|
||||
}
|
||||
@@ -29,6 +29,13 @@ client_suites:
|
||||
default: false
|
||||
safety:
|
||||
default: false
|
||||
integration_suites:
|
||||
api-contract:
|
||||
default: false
|
||||
authorization:
|
||||
default: false
|
||||
build-contract:
|
||||
default: false
|
||||
vars: {}
|
||||
authorize:
|
||||
mutating: AUTHORIZE_MUTATING
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
CATALOG = SCRIPTS / "catalog.py"
|
||||
CASES_CSV = SCRIPTS / "generate_cases_csv.py"
|
||||
RUN_CSV = SCRIPTS / "generate_run_csv.py"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from two_tier import classify_integration, run_integration_first
|
||||
|
||||
|
||||
def write_mixed_catalog(root: Path) -> None:
|
||||
tester = root / ".pouch" / "tester"
|
||||
(tester / "cases").mkdir(parents=True)
|
||||
(tester / "runs").mkdir()
|
||||
(tester / "catalog.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"version": 2,
|
||||
"id_pattern": "^TC-[A-Z]+-[0-9]+$",
|
||||
"categories": [{"id": "MIXED"}],
|
||||
"risks": ["readonly", "mutating", "destructive"],
|
||||
"default_max_risk": "readonly",
|
||||
"client_suites": {
|
||||
"client-smoke": {"default": True},
|
||||
"client-regression": {"default": False},
|
||||
"safety": {"default": False},
|
||||
},
|
||||
"sessions": {"browser": {"kind": "browser", "reuse": "run"}},
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
journey = {
|
||||
"id": "TC-JOURNEY-001",
|
||||
"test_type": "journey",
|
||||
"category": "MIXED",
|
||||
"title": "journey",
|
||||
"what": "client path",
|
||||
"lifecycle": "active",
|
||||
"tag": "smoke",
|
||||
"risk": "readonly",
|
||||
"driver": "browser",
|
||||
"automation": "automated",
|
||||
"session": "browser",
|
||||
"client_suite": "client-smoke",
|
||||
"platform": "android",
|
||||
"journey": {
|
||||
"goal": "goal",
|
||||
"entry_state": "logged_out",
|
||||
"actions": ["login"],
|
||||
"assertions": ["visible"],
|
||||
"final_state": "done",
|
||||
"failure_policy": "stop",
|
||||
},
|
||||
"fixture": {"account": "test-role"},
|
||||
"cleanup": {"required": False, "reason": "read-only"},
|
||||
"how": {"steps": ["open"]},
|
||||
"expected": [{"kind": "visible-text", "value": "done"}],
|
||||
}
|
||||
integration = {
|
||||
"id": "TC-INTEGRATION-001",
|
||||
"test_type": "integration",
|
||||
"category": "MIXED",
|
||||
"title": "contract",
|
||||
"what": "API contract",
|
||||
"lifecycle": "active",
|
||||
"tag": "slow",
|
||||
"risk": "readonly",
|
||||
"driver": "script",
|
||||
"automation": "automated",
|
||||
"integration_suite": "api-contract",
|
||||
"fixture": {"references": ["isolated-account"]},
|
||||
"request": {"method": "GET", "path": "/health"},
|
||||
"how": {"run": "true"},
|
||||
"cleanup": {"required": False, "reason": "read-only"},
|
||||
"expected": [{"kind": "api-status", "value": 200}],
|
||||
}
|
||||
(tester / "cases" / "mixed.yaml").write_text(
|
||||
yaml.safe_dump({"category": "MIXED", "cases": [journey, integration]}, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TwoTierCatalogTests(unittest.TestCase):
|
||||
def run_catalog(self, root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CATALOG), *args, "--root", str(root)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
def test_default_selection_excludes_integration_and_explicit_selection_includes_it(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
write_mixed_catalog(root)
|
||||
valid = self.run_catalog(root, "validate")
|
||||
self.assertEqual(valid.returncode, 0, valid.stderr)
|
||||
|
||||
default = self.run_catalog(root, "select")
|
||||
self.assertEqual(default.returncode, 0, default.stderr)
|
||||
default_payload = json.loads(default.stdout)
|
||||
self.assertEqual(default_payload["count"], 1)
|
||||
self.assertEqual(default_payload["test_types"], {"journey": 1, "integration": 0})
|
||||
|
||||
integration = self.run_catalog(root, "select", "--test-type", "integration")
|
||||
self.assertEqual(integration.returncode, 0, integration.stderr)
|
||||
integration_payload = json.loads(integration.stdout)
|
||||
self.assertEqual(integration_payload["count"], 1)
|
||||
self.assertEqual(integration_payload["cases"][0]["test_type"], "integration")
|
||||
|
||||
csv_result = subprocess.run(
|
||||
[sys.executable, str(CASES_CSV), "--root", str(root)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(csv_result.returncode, 0, csv_result.stderr)
|
||||
with (root / ".pouch/tester/cases.csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
self.assertEqual({row["test_type"] for row in rows}, {"journey", "integration"})
|
||||
self.assertEqual({row["suite"] for row in rows}, {"client-smoke", "api-contract"})
|
||||
|
||||
run_path = root / ".pouch/tester/runs/two-tier.yaml"
|
||||
run_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{"runId": "two-tier", "results": [
|
||||
{"id": "TC-INTEGRATION-001", "result": "pass"},
|
||||
{"id": "TC-JOURNEY-001", "result": "not-run"},
|
||||
]},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
run_csv = subprocess.run(
|
||||
[sys.executable, str(RUN_CSV), "--root", str(root), "--run", str(run_path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(run_csv.returncode, 0, run_csv.stderr)
|
||||
with run_path.with_suffix(".csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||
report_rows = list(csv.DictReader(handle))
|
||||
self.assertEqual(report_rows[0]["test_type"], "integration")
|
||||
self.assertEqual(report_rows[0]["suite"], "api-contract")
|
||||
|
||||
|
||||
class IntegrationFirstTests(unittest.TestCase):
|
||||
def test_preflight_block_does_not_create_session(self):
|
||||
calls: list[str] = []
|
||||
result = run_integration_first(
|
||||
preflight_status="blocked",
|
||||
integration_cases=["INT-1"],
|
||||
journey_cases=["J-1"],
|
||||
execute_integration=lambda case: calls.append("integration") or "pass",
|
||||
create_journey_session=lambda: calls.append("session"),
|
||||
execute_journey=lambda session, case: "pass",
|
||||
)
|
||||
self.assertEqual(result["results"][0]["result"], "not-run")
|
||||
self.assertFalse(result["session_created"])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_integration_failure_propagates_journey_not_run(self):
|
||||
calls: list[str] = []
|
||||
result = run_integration_first(
|
||||
preflight_status="ready",
|
||||
integration_cases=["INT-1", "INT-2"],
|
||||
journey_cases=["J-1", "J-2"],
|
||||
execute_integration=lambda case: calls.append(case) or ("fail" if case == "INT-1" else "pass"),
|
||||
create_journey_session=lambda: calls.append("session"),
|
||||
execute_journey=lambda session, case: "pass",
|
||||
)
|
||||
self.assertEqual(result["status"], "fail")
|
||||
self.assertEqual(calls, ["INT-1"])
|
||||
self.assertFalse(result["session_created"])
|
||||
self.assertEqual([row["result"] for row in result["results"]], ["fail", "not-run", "not-run", "not-run"])
|
||||
|
||||
def test_service_unreachable_is_environment_failure(self):
|
||||
self.assertEqual(classify_integration(ok=False, service_reachable=False), "environment-fail")
|
||||
self.assertEqual(classify_integration(ok=False, service_reachable=True), "fail")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user