Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions fixtures/repro-specs/hook-moment-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
56 changes: 56 additions & 0 deletions scripts/verify/list-ci-specs.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 目录不存在 → 等价 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);
});
69 changes: 69 additions & 0 deletions scripts/verify/list-specs-core.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
]);
});
46 changes: 46 additions & 0 deletions scripts/verify/list-specs-core.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
12 changes: 12 additions & 0 deletions scripts/verify/repro-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 各自一份)。 */
Expand Down
Loading