diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 092b074..b8bd658 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -26,40 +26,69 @@ jobs: outputs: specs: ${{ steps.list.outputs.specs }} skipReproReason: ${{ steps.list.outputs.skipReason }} + excludedSpecs: ${{ steps.list.outputs.excludedSpecs }} steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha }} - - id: list + # skip-repro label 是「PR 级显式豁免」,优先于 spec.ci.skip(spec 级豁免)。 + # 标签命中 → 不必装 pnpm/node 跑 list-ci-specs,直接早退。 + - id: label-shortcut shell: bash env: PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} run: | - # 显式豁免 (A): PR 带 skip-repro label if echo "$PR_LABELS" | jq -e '. | index("skip-repro")' >/dev/null; then - echo "specs=[]" >> "$GITHUB_OUTPUT" - echo "skipReason=skip-repro label" >> "$GITHUB_OUTPUT" + echo "hit=true" >> "$GITHUB_OUTPUT" echo "::notice::PR 带 skip-repro label —— 跳过 repro 检查" - exit 0 + else + echo "hit=false" >> "$GITHUB_OUTPUT" fi - # 列 fixtures/repro-specs/*.ts(排除 README/index/共享文件) - if [[ ! -d fixtures/repro-specs ]]; then - echo "specs=[]" >> "$GITHUB_OUTPUT" - echo "skipReason=no-repro-specs-dir" >> "$GITHUB_OUTPUT" - echo "::warning::fixtures/repro-specs 目录不存在 —— bootstrap 期豁免,跳过 repro 检查" + # 没命中 skip-repro label,装依赖跑 list-ci-specs.ts 读 spec.ci.skip 分桶。 + # (要装 pnpm/node 是因为 spec 是 .ts,需要 tsx + 解析它的 import) + - if: steps.label-shortcut.outputs.hit != 'true' + uses: ./.github/actions/setup-repo + - id: list + shell: bash + env: + LABEL_HIT: ${{ steps.label-shortcut.outputs.hit }} + run: | + set -euo pipefail + if [[ "$LABEL_HIT" == "true" ]]; then + echo 'specs=[]' >> "$GITHUB_OUTPUT" + echo 'excludedSpecs=[]' >> "$GITHUB_OUTPUT" + echo 'skipReason=skip-repro label' >> "$GITHUB_OUTPUT" exit 0 fi - mapfile -t files < <(find fixtures/repro-specs -maxdepth 1 -name '*.ts' ! -name 'index.ts' | sort) - if [[ ${#files[@]} -eq 0 ]]; then - echo "specs=[]" >> "$GITHUB_OUTPUT" - echo "skipReason=empty-repro-specs-dir" >> "$GITHUB_OUTPUT" - echo "::warning::fixtures/repro-specs 为空 —— bootstrap 期豁免,跳过 repro 检查" - exit 0 + # list-ci-specs.ts 输出形如 {"included":[...],"excluded":[{...}],"note":"..."} + payload=$(npx tsx scripts/verify/list-ci-specs.ts) + echo "list-ci-specs raw: $payload" + included=$(echo "$payload" | jq -c '.included') + excluded=$(echo "$payload" | jq -c '.excluded') + note=$(echo "$payload" | jq -r '.note // ""') + echo "specs=$included" >> "$GITHUB_OUTPUT" + echo "excludedSpecs=$excluded" >> "$GITHUB_OUTPUT" + # 决定 skipReason —— 仅在 included 为空时才填,否则保持空(repro 会真跑) + incl_count=$(echo "$included" | jq 'length') + excl_count=$(echo "$excluded" | jq 'length') + if [[ "$incl_count" == "0" ]]; then + if [[ -n "$note" ]]; then + reason="$note" + elif [[ "$excl_count" == "0" ]]; then + reason="no-specs-discovered" + else + # 全部 spec 都被 ci.skip 标记 → 把它们的 reason 拼起来 + reason="all-specs-ci-skip: $(echo "$excluded" | jq -r 'map(.file + " (\(.reason))") | join("; ")')" + fi + echo "skipReason=$reason" >> "$GITHUB_OUTPUT" + echo "::warning::没有需要跑的 spec ($reason)" + else + echo 'skipReason=' >> "$GITHUB_OUTPUT" + echo "找到 included spec: $included" + if [[ "$excl_count" != "0" ]]; then + echo "::notice::被豁免的 spec(ci.skip=true): $excluded" + fi fi - json=$(printf '%s\n' "${files[@]}" | jq -R . | jq -sc .) - echo "specs=$json" >> "$GITHUB_OUTPUT" - echo "skipReason=" >> "$GITHUB_OUTPUT" - echo "找到 spec: ${files[*]}" repro: needs: list-specs @@ -131,21 +160,27 @@ jobs: REPRO_OUTCOME: ${{ needs.repro.result }} # success / failure / skipped / cancelled TESTS_OUTCOME: ${{ needs.tests.result }} SKIP_REPRO_REASON: ${{ needs.list-specs.outputs.skipReproReason }} + EXCLUDED_SPECS: ${{ needs.list-specs.outputs.excludedSpecs }} run: | mkdir -p /tmp/agg + # 把 ci.skip 标记的 spec 渲成一段附注(给 repro_summary 用,让 verdict comment 显示): + excluded_note="" + if [[ -n "$EXCLUDED_SPECS" && "$EXCLUDED_SPECS" != "[]" ]]; then + excluded_note=" · 显式豁免: $(echo "$EXCLUDED_SPECS" | jq -r 'map(.file + " (\(.reason))") | join("; ")')" + fi # repro_ok 计算(三态豁免见安全前提): # - REPRO_OUTCOME = success → ok - # - REPRO_OUTCOME = skipped 且 SKIP_REPRO_REASON 非空 → ok(显式豁免:bootstrap / docs-only) + # - REPRO_OUTCOME = skipped 且 SKIP_REPRO_REASON 非空 → ok(显式豁免:bootstrap / docs-only / all-ci-skip) # - 其它 → 不 ok if [[ "$REPRO_OUTCOME" == "success" ]]; then repro_ok=true - repro_summary="verdict=pass (all specs)" + repro_summary="verdict=pass (all included specs)$excluded_note" elif [[ "$REPRO_OUTCOME" == "skipped" && -n "$SKIP_REPRO_REASON" ]]; then repro_ok=true repro_summary="豁免 — $SKIP_REPRO_REASON" else repro_ok=false - repro_summary="verdict=fail/missing (REPRO_OUTCOME=$REPRO_OUTCOME)" + repro_summary="verdict=fail/missing (REPRO_OUTCOME=$REPRO_OUTCOME)$excluded_note" fi if [[ "$TESTS_OUTCOME" == "success" ]]; then tests_ok=true diff --git a/fixtures/repro-specs/hook-moment-block.ts b/fixtures/repro-specs/hook-moment-block.ts index 8d5312f..62b1686 100644 --- a/fixtures/repro-specs/hook-moment-block.ts +++ b/fixtures/repro-specs/hook-moment-block.ts @@ -54,6 +54,14 @@ const spec: ReproSpec = { windowTitle: "TADEMOREC", durationSec: 40, }, + // CI 上跳过此 spec —— baseline.ref="HEAD" 数据驱动差异依赖 $TA_DEMO_STAGE 下 + // home-empty/home-loaded 两份知识库目录预置(见上方注释),Ubuntu runner 没那 + // 目录、也没 Win32 EnumWindows 录 GIF 的能力。本地手工跑仍然有效。 + // 见 https://github.com/libz-renlab-ai/Matrix/issues/3 Gap 1。 + ci: { + skip: true, + reason: "需 $TA_DEMO_STAGE 下 home-empty/home-loaded 两份知识库目录预置 + 录 GIF 仅 Win32 可行", + }, }; export default spec; diff --git a/scripts/verify/list-ci-specs.ts b/scripts/verify/list-ci-specs.ts new file mode 100644 index 0000000..ec3aa13 --- /dev/null +++ b/scripts/verify/list-ci-specs.ts @@ -0,0 +1,56 @@ +// scripts/verify/list-ci-specs.ts +// +// IO 壳:发现 fixtures/repro-specs/*.ts → 动态 import → 抽 ci 字段 → +// 调 list-specs-core.filterCiSpecs → 把 { included, excluded } JSON 打到 stdout。 +// +// 给 pr-review.yml 的 list-specs job 读。issue #3 Gap 1 替换原来 inline bash 的 +// find ... -name '*.ts' 逻辑(那条没法读 spec.ci 字段)。 + +import { readdirSync, existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { filterCiSpecs, type DiscoveredSpec } from "./list-specs-core.ts"; +import type { ReproSpec } from "./repro-types.ts"; + +const SPEC_DIR = "fixtures/repro-specs"; + +async function main(): Promise { + // 目录不存在 → 等价 empty(让 verdict 走「bootstrap 期豁免」路径) + if (!existsSync(SPEC_DIR)) { + process.stdout.write(JSON.stringify({ included: [], excluded: [], note: "no-repro-specs-dir" }) + "\n"); + return; + } + + // 列 *.ts(排除 README/index/共享文件)。windows 上 path.sep = '\\',统一成 '/'。 + const files = readdirSync(SPEC_DIR) + .filter((f) => f.endsWith(".ts") && f !== "index.ts") + .map((f) => path.posix.join(SPEC_DIR, f)) + .sort(); + + if (files.length === 0) { + process.stdout.write(JSON.stringify({ included: [], excluded: [], note: "empty-repro-specs-dir" }) + "\n"); + return; + } + + // 动态 import 每个 spec —— Windows 下 import 路径必须是 file:// URL + const specs: DiscoveredSpec[] = []; + for (const file of files) { + const url = pathToFileURL(path.resolve(file)).href; + const mod = await import(url); + const spec: ReproSpec | undefined = mod.default ?? mod.spec; + if (!spec || typeof spec.id !== "string") { + // 异常 spec 不静默吞掉 —— 把它当作 included 让 repro job 自己 fail 出来,而不是 list-specs 帮忙掩盖 + specs.push({ file }); + continue; + } + specs.push({ file, ci: spec.ci }); + } + + const r = filterCiSpecs(specs); + process.stdout.write(JSON.stringify(r) + "\n"); +} + +main().catch((e: Error) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/verify/list-specs-core.test.ts b/scripts/verify/list-specs-core.test.ts new file mode 100644 index 0000000..d2974d7 --- /dev/null +++ b/scripts/verify/list-specs-core.test.ts @@ -0,0 +1,69 @@ +// scripts/verify/list-specs-core.test.ts +// +// Issue #3 Gap 1: 给 ReproSpec 加 ci: { skip?: boolean; reason?: string } +// 字段后,pr-review.yml 的 list-specs job 需要把 ci.skip = true 的 spec +// 过滤出去。这里测的是「过滤」纯函数。 +// +// 与同目录 list-specs-core.ts 配对,严禁 import fs / child_process。 + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { filterCiSpecs, type DiscoveredSpec } from "./list-specs-core.ts"; + +test("filterCiSpecs: 空数组 → 两边都空", () => { + const r = filterCiSpecs([]); + assert.deepEqual(r, { included: [], excluded: [] }); +}); + +test("filterCiSpecs: 无 ci 字段 → 全部 included", () => { + const specs: DiscoveredSpec[] = [ + { file: "fixtures/repro-specs/a.ts" }, + { file: "fixtures/repro-specs/b.ts" }, + ]; + const r = filterCiSpecs(specs); + assert.deepEqual(r.included, ["fixtures/repro-specs/a.ts", "fixtures/repro-specs/b.ts"]); + assert.deepEqual(r.excluded, []); +}); + +test("filterCiSpecs: ci.skip = false → included(不是 truthy 就算保留)", () => { + const r = filterCiSpecs([{ file: "x.ts", ci: { skip: false } }]); + assert.deepEqual(r.included, ["x.ts"]); + assert.deepEqual(r.excluded, []); +}); + +test("filterCiSpecs: ci 字段存在但无 skip → included", () => { + const r = filterCiSpecs([{ file: "x.ts", ci: { reason: "无意义,因为没 skip" } }]); + assert.deepEqual(r.included, ["x.ts"]); + assert.deepEqual(r.excluded, []); +}); + +test("filterCiSpecs: ci.skip = true + reason → excluded + 带 reason", () => { + const r = filterCiSpecs([ + { file: "fixtures/repro-specs/hook-moment-block.ts", ci: { skip: true, reason: "需 demo-stage 目录预置" } }, + ]); + assert.deepEqual(r.included, []); + assert.deepEqual(r.excluded, [ + { file: "fixtures/repro-specs/hook-moment-block.ts", reason: "需 demo-stage 目录预置" }, + ]); +}); + +test("filterCiSpecs: ci.skip = true 但无 reason → excluded + 默认 reason 'ci.skip = true'", () => { + const r = filterCiSpecs([{ file: "x.ts", ci: { skip: true } }]); + assert.equal(r.included.length, 0); + assert.equal(r.excluded.length, 1); + assert.equal(r.excluded[0]!.reason, "ci.skip = true"); +}); + +test("filterCiSpecs: 混合 —— included 与 excluded 都正确分桶且保序", () => { + const r = filterCiSpecs([ + { file: "a.ts" }, + { file: "b.ts", ci: { skip: true, reason: "原因 B" } }, + { file: "c.ts", ci: { skip: false } }, + { file: "d.ts", ci: { skip: true } }, // 无 reason 走默认 + ]); + assert.deepEqual(r.included, ["a.ts", "c.ts"]); + assert.deepEqual(r.excluded, [ + { file: "b.ts", reason: "原因 B" }, + { file: "d.ts", reason: "ci.skip = true" }, + ]); +}); diff --git a/scripts/verify/list-specs-core.ts b/scripts/verify/list-specs-core.ts new file mode 100644 index 0000000..cf7987a --- /dev/null +++ b/scripts/verify/list-specs-core.ts @@ -0,0 +1,46 @@ +// scripts/verify/list-specs-core.ts +// +// Issue #3 Gap 1 纯逻辑核心:把已发现的 ReproSpec 列表按 ci.skip 分桶。 +// +// 严禁 import fs / child_process —— functional core,IO 在 list-ci-specs.ts。 + +/** ReproSpec 的 CI 行为配置子集(完整定义在 repro-types.ts 的 SpecCi)。 */ +export interface SpecCi { + /** true 时:CI 上跑 list-specs 时把这条 spec 过滤掉,verdict 把它当豁免。 */ + skip?: boolean; + /** 给人看的跳过原因。pr-review.yml 的 verdict comment 会展示。 */ + reason?: string; +} + +/** list-specs job 从磁盘读到的 spec 概要 —— 只关心 ci 字段,不关心 spec 全部内容。 */ +export interface DiscoveredSpec { + /** spec 文件相对路径,例如 "fixtures/repro-specs/hook-moment-block.ts" */ + file: string; + ci?: SpecCi; +} + +/** filterCiSpecs 的产出:included 喂 repro matrix,excluded 喂 verdict 的豁免列表。 */ +export interface FilterResult { + included: string[]; + excluded: Array<{ file: string; reason: string }>; +} + +/** + * 按 ci.skip 分桶。语义: + * - ci 缺省 / ci.skip = false / ci.skip 缺省 → included + * - ci.skip === true → excluded;reason = ci.reason ?? "ci.skip = true" + * + * 保序:输入顺序在 included 与 excluded 内部都保留。 + */ +export function filterCiSpecs(specs: DiscoveredSpec[]): FilterResult { + const included: string[] = []; + const excluded: Array<{ file: string; reason: string }> = []; + for (const s of specs) { + if (s.ci?.skip === true) { + excluded.push({ file: s.file, reason: s.ci.reason ?? "ci.skip = true" }); + } else { + included.push(s.file); + } + } + return { included, excluded }; +} diff --git a/scripts/verify/repro-types.ts b/scripts/verify/repro-types.ts index 1c76623..7bb75b5 100644 --- a/scripts/verify/repro-types.ts +++ b/scripts/verify/repro-types.ts @@ -51,6 +51,16 @@ export interface ResultMatcher { stderrNotContains?: string[]; } +/** CI 行为配置。skip=true 时 pr-review.yml 的 list-specs job 会把此 spec 从 + * repro matrix 里过滤掉,并把它当显式豁免计入 verdict(不再让 spec 无脑 fail)。 + * 典型用例:依赖手工预置目录 / Windows-only / 需 root 权限等无法在 Ubuntu runner 上重现的 spec。 + * 纯过滤逻辑见 scripts/verify/list-specs-core.ts; issue #3 Gap 1 落地此字段。 */ +export interface SpecCi { + skip?: boolean; + /** 给人看的跳过原因;verdict comment 与日志都会展示。 */ + reason?: string; +} + export interface ReproSpec { id: string; // kebab-case,比如 "hook-moment-block" description: string; @@ -63,6 +73,8 @@ export interface ReproSpec { scenario?: Scenario; /** 给 GIF 录制用的可视化重演脚本。无此字段则 CLI 不录 GIF。 */ demoScene?: DemoScene; + /** CI 上的行为配置;主要给 pr-review.yml 的 list-specs job 读。 */ + ci?: SpecCi; } /** 单个 step 的实际执行结果(baseline + current 各自一份)。 */