diff --git a/skills/builder/scripts/test_verify_deb.sh b/skills/builder/scripts/test_verify_deb.sh new file mode 100755 index 0000000..ca80cd7 --- /dev/null +++ b/skills/builder/scripts/test_verify_deb.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repro: 755 maintainer scripts must pass; 644 must fail as not executable. +set -euo pipefail + +root=$(cd "$(dirname "$0")" && pwd) +verify=$root/verify_deb.sh +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT + +make_deb_755() { + local dest=$1 + local pkg=$scratch/pkg-755 + rm -rf "$pkg" + mkdir -p "$pkg/DEBIAN" "$pkg/usr/bin" + printf '#!/bin/sh\nexit 0\n' >"$pkg/usr/bin/noop" + chmod 755 "$pkg/usr/bin/noop" + cat >"$pkg/DEBIAN/control" <<'EOF' +Package: verify-deb-fixture +Version: 0.0.1 +Architecture: amd64 +Maintainer: test +Description: fixture for verify_deb.sh permission check +EOF + printf '#!/bin/sh\nexit 0\n' >"$pkg/DEBIAN/postinst" + chmod 755 "$pkg/DEBIAN/postinst" + dpkg-deb --build "$pkg" "$dest" >/dev/null +} + +# dpkg-deb --build rejects 644 maintainer scripts; strip the execute bit in the +# control tarball so verify_deb.sh still sees a real packaged 644 postinst. +make_deb_644_from() { + local src=$1 dest=$2 + local work=$scratch/mutate + rm -rf "$work" + mkdir -p "$work/control" + cp "$src" "$dest" + ( cd "$work" && ar x "$dest" ) + zstd -d -c "$work/control.tar.zst" | tar -C "$work/control" -xf - + chmod 644 "$work/control/postinst" + tar -C "$work/control" -cf - . | zstd -c >"$work/control.tar.zst" + ( cd "$work" && ar r "$dest" control.tar.zst ) +} + +ok_deb=$scratch/verify-deb-fixture_0.0.1_amd64.deb +bad_deb=$scratch/verify-deb-fixture-noexec_0.0.1_amd64.deb +make_deb_755 "$ok_deb" +make_deb_644_from "$ok_deb" "$bad_deb" + +ok_out=$scratch/ok.out +bad_out=$scratch/bad.out +set +e +"$verify" "$ok_deb" 0.0.1 amd64 >"$ok_out" 2>&1 +ok_rc=$? +"$verify" "$bad_deb" 0.0.1 amd64 >"$bad_out" 2>&1 +bad_rc=$? +set -e + +fail=0 +if [[ $ok_rc -ne 0 ]] || ! grep -q 'VERIFY: OK' "$ok_out"; then + echo "FAIL: 755 postinst should be treated as executable" >&2 + cat "$ok_out" >&2 + fail=1 +fi +if [[ $bad_rc -eq 0 ]] || ! grep -q 'not executable' "$bad_out"; then + echo "FAIL: 644 postinst should fail as not executable" >&2 + cat "$bad_out" >&2 + fail=1 +fi +if ((fail)); then + exit 1 +fi +echo "test_verify_deb.sh: OK" diff --git a/skills/builder/scripts/verify_deb.sh b/skills/builder/scripts/verify_deb.sh index 2f9b014..1f736d6 100755 --- a/skills/builder/scripts/verify_deb.sh +++ b/skills/builder/scripts/verify_deb.sh @@ -87,8 +87,10 @@ if dpkg-deb --control "$package" "$control_dir" 2>/dev/null; then for script in preinst postinst prerm postrm; do if [[ -f "$control_dir/$script" ]]; then found_scripts=true - mode=$(stat -c '%a' "$control_dir/$script") - if [[ $mode =~ .*[2367]$ ]]; then + # Use -x: octal last digit [2367] is "other write", so 755 (r-x) was + # reported as not executable. 644 still fails because it is not -x. + mode=$(stat -c '%a' "$control_dir/$script" 2>/dev/null || stat -f '%OLp' "$control_dir/$script") + if [[ -x "$control_dir/$script" ]]; then echo "OK: $script mode $mode" else echo "FAIL: $script not executable (mode $mode)" >&2 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 96b8029..4882a52 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -87,4 +87,4 @@ python3 /scripts/catalog.py show --root --id ## 运行 -读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 surface 分流。未指定范围时等同「suite=smoke 且风险上限=默认」。 +读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 surface 分流。未指定范围时等同「suite=smoke 且风险上限=默认」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。 diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index 71a02ce..4f9ad5a 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -16,6 +16,8 @@ python3 /scripts/catalog.py select --root \ `select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。 +若 catalog 有 `run_together`,命中组内任一条就把整组按 `ids` 顺序扩进列表(JSON 带 `run_together`)。这不是擅自扩大范围。 + 无命中:停下说明筛选条件,不要扩大范围。 ## 2. 运行前闸 @@ -37,6 +39,8 @@ python3 /scripts/catalog.py select --root \ 按 select 返回的顺序,一条一条来。select 按 `catalog.yaml` 的 `categories` 顺序,同一分类内按该 yaml 列表顺序(不是文件名字母序)。默认失败即停(产品 `fail` 或探测到的不安全状态)。用户说继续 / keep-going 才往后跑。 +例外:当前条和下一条同属 `run_together` 且该组 `continue_on_fail: true` 时,前一条产品 `fail` 仍跑后一条。`environment-fail` 仍整轮停。 + **script** 有 `how.run`: diff --git a/skills/tester/references/schema.md b/skills/tester/references/schema.md index 56942b6..ea090de 100644 --- a/skills/tester/references/schema.md +++ b/skills/tester/references/schema.md @@ -24,6 +24,7 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对 | `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` | | `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) | | `id_pattern` | 用例 id 正则 | +| `run_together` | 可选。成组执行:`[{ids: [TC-…, TC-…], continue_on_fail: true}]`。点到组内任一条,`select` 会按 `ids` 顺序把整组扩进命中列表。`continue_on_fail: true` 时组内前一条产品 fail 仍跑后一条(返回/收尾)。一个 id 只能属于一组。 | `local.yaml` 与环境变量覆盖 `HOST` 和授权开关。查找顺序:环境变量 > `local.yaml` > `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。 diff --git a/skills/tester/scripts/catalog.py b/skills/tester/scripts/catalog.py index 38547de..b58f1d0 100755 --- a/skills/tester/scripts/catalog.py +++ b/skills/tester/scripts/catalog.py @@ -264,6 +264,93 @@ def parse_ids(values: list[str]) -> list[str]: return out +def public_case(case: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in case.items() if not str(k).startswith("_")} + + +def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]: + raw = meta.get("run_together") or [] + if not isinstance(raw, list): + return [] + groups: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + ids = [str(x) for x in (item.get("ids") or []) if str(x).strip()] + if len(ids) < 2: + continue + groups.append( + { + "ids": ids, + "continue_on_fail": bool(item.get("continue_on_fail")), + } + ) + return groups + + +def expand_run_together( + meta: dict[str, Any], + all_cases: list[dict[str, Any]], + selected: list[dict[str, Any]], + skipped: list[dict[str, str]], + max_i: int, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + groups = run_together_groups(meta) + if not groups: + return selected, [] + by_id = {str(c.get("id") or ""): c for c in all_cases} + selected_ids = [str(c.get("id") or "") for c in selected] + selected_map = {str(c.get("id") or ""): c for c in selected} + hit_groups: list[dict[str, Any]] = [] + + for group in groups: + ids = group["ids"] + if not any(cid in selected_map for cid in ids): + continue + hit_groups.append(group) + first_pos = min( + (selected_ids.index(cid) for cid in ids if cid in selected_ids), + default=len(selected_ids), + ) + members: list[dict[str, Any]] = [] + for cid in ids: + if cid in selected_map: + members.append(selected_map[cid]) + continue + case = by_id.get(cid) + if not case or case.get("status", "active") != "active": + skipped.append({"id": cid, "reason": "run_together 搭档不是 active"}) + continue + risk = str(case.get("risk") or "safe") + try: + r_i = risk_index(meta, risk) + except SystemExit: + skipped.append({"id": cid, "reason": f"未知风险 {risk}"}) + continue + if r_i > max_i: + skipped.append({"id": cid, "reason": f"risk={risk} 超过上限"}) + continue + members.append(public_case(case)) + drop = set(ids) + new_selected: list[dict[str, Any]] = [] + inserted = False + for case in selected: + cid = str(case.get("id") or "") + if cid in drop: + if not inserted: + new_selected.extend(members) + inserted = True + continue + new_selected.append(case) + if not inserted: + new_selected.extend(members) + selected = new_selected + selected_ids = [str(c.get("id") or "") for c in selected] + selected_map = {str(c.get("id") or ""): c for c in selected} + + return selected, hit_groups + + def cmd_select(args: argparse.Namespace, root: Path) -> int: meta = load_catalog_meta(root) cases = load_all_cases(root) @@ -305,8 +392,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: if r_i > max_i: skipped.append({"id": cid, "reason": f"risk={risk} 超过上限 {max_risk}"}) continue - public = {k: v for k, v in case.items() if not str(k).startswith("_")} - selected.append(public) + selected.append(public_case(case)) if wanted and missing: print( @@ -315,12 +401,15 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: ) return 2 + selected, together = expand_run_together(meta, cases, selected, skipped, max_i) + print( json.dumps( { "max_risk": max_risk, "count": len(selected), "skipped": skipped, + "run_together": together, "cases": selected, }, ensure_ascii=False, @@ -405,6 +494,28 @@ def validate(root: Path) -> list[str]: except SystemExit as exc: return errors + [str(exc)] + together_raw = meta.get("run_together") + if together_raw is None: + together_raw = [] + if not isinstance(together_raw, list): + errors.append("catalog.run_together 必须是列表") + together_raw = [] + together_seen: set[str] = set() + for i, item in enumerate(together_raw): + where = f"catalog.run_together[{i}]" + if not isinstance(item, dict): + errors.append(f"{where}: 必须是 mapping,含 ids") + continue + ids = item.get("ids") + if not isinstance(ids, list) or len(ids) < 2: + errors.append(f"{where}: ids 至少两个") + continue + for cid in ids: + cid_s = str(cid) + if cid_s in together_seen: + errors.append(f"{where}: id {cid_s} 已在另一组 run_together") + together_seen.add(cid_s) + for case in cases: cid = str(case.get("id") or "") where = f"{case.get('_file')}:{cid or '?'}" @@ -416,6 +527,8 @@ def validate(root: Path) -> list[str]: if cid in seen: errors.append(f"{where}: id 重复(也出现在 {seen[cid]})") seen[cid] = str(case.get("_file")) + if cid in together_seen: + together_seen.discard(cid) cat = str(case.get("category") or "") file_cat = str(case.get("_file_category") or "") if file_cat and cat and cat != file_cat: @@ -471,6 +584,8 @@ def validate(root: Path) -> list[str]: errors.append(f"{where}: expected[{j}] 需要 kind 与 value") elif sig.get("kind") not in EXPECTED_KINDS: errors.append(f"{where}: expected[{j}].kind 非法") + if together_seen: + errors.append("catalog.run_together 引用了不存在的 id: " + ", ".join(sorted(together_seen))) return errors