fix(ci): run Telegram skill tests when a PR changes only skill test files (#163328)

The Telegram userbot skill's node:test (*.test.mjs) and Python (*.test.py)
files are not Vitest-routable, so aggressive PR selection drops them as
direct targets. Their only CI owner is the Vitest wrapper
test/scripts/telegram-e2e-userbot-skill.test.ts, which the policy watch
selected for 20 named non-test scripts only. Test-only skill PRs (e.g.
#162968) and later scripts (reply-policy-*, telegram-runtime) planned
nothing in either selection mode.

Watch the skill's whole scripts/ directory, the agents/openai.yaml the
wrapper parses, and the fixture it loads by URL. The wrapper stays in the
release-only core-tooling family; it now also runs on PRs that touch the
skill, the only PRs that can break these self-contained scripts.
This commit is contained in:
Peter Steinberger 2026-10-02 09:55:09 -07:00 • committed by GitHub
parent d20ba7392c
commit 5ab66a5fe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 59 additions and 35 deletions

View file

@ -125,7 +125,10 @@ and scripts that the import graph cannot discover.
Explicit policy watches retain their matching tests in PR CI even when the broader
tooling or runtime suite is deferred. This includes wrapper dependency checks,
Gateway client callsite scans, and upgrade-survivor package checks. Unrelated
deferred tests stay excluded.
deferred tests stay excluded. Changed test files that no Vitest config routes,
such as skill `node:test` and Python suites, are never direct targets; the watch
for their Vitest wrapper must cover those test files too, or test-only edits run
in no PR job.
Aggressive PR selection keeps changed tests and direct runtime import consumers,
including package and SDK aliases. A changed module with fewer than 20 direct

View file

@ -1966,28 +1966,13 @@ const policyTestWatches: readonly PolicyTestWatch[] = [
],
},
{
// Sole CI owner of the skill's node:test and Python suites: no Vitest config
// routes those files, so test-only skill edits must select this wrapper.
testFile: "test/scripts/telegram-e2e-userbot-skill.test.ts",
watchGlobs: [
".agents/skills/telegram-e2e-userbot/scripts/followup-drain-control-preload.mjs",
".agents/skills/telegram-e2e-userbot/scripts/published-upgrade-artifact.mjs",
".agents/skills/telegram-e2e-userbot/scripts/published-upgrade-scenario.mjs",
".agents/skills/telegram-e2e-userbot/scripts/qa-credential-lease.mjs",
".agents/skills/telegram-e2e-userbot/scripts/run-mock-sut-user-e2e.mjs",
".agents/skills/telegram-e2e-userbot/scripts/run-published-upgrade-user-e2e.mjs",
".agents/skills/telegram-e2e-userbot/scripts/scenario.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-api-ignore-abort-preload.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-binding-checkpoint.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-binding-forum.py",
".agents/skills/telegram-e2e-userbot/scripts/telegram-binding-upgrade-verdict.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-run-scope.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-test-credential.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-test-doctor.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-test-group.mjs",
".agents/skills/telegram-e2e-userbot/scripts/telegram-test-recover.mjs",
".agents/skills/telegram-e2e-userbot/scripts/triage-mock-openai.mjs",
".agents/skills/telegram-e2e-userbot/scripts/user-driver.py",
".agents/skills/telegram-e2e-userbot/scripts/user-record.py",
".agents/skills/telegram-e2e-userbot/agents/openai.yaml",
".agents/skills/telegram-e2e-userbot/scripts/**",
"test/scripts/fixtures/triage-fixture-startup.mjs",
],
},
{

View file

@ -44,19 +44,29 @@ it.each(["added", "renamed", "deleted", "import-edge"])(
},
);
it("selects changed test files directly only on routable Vitest surfaces", () => {
const cwd = tempDirs.make("node-selection-routable-");
// Skill scripts carry node:test files that no Vitest config owns.
const skillTest = ".agents/skills/example/scripts/driver.test.mjs";
const routable = "src/infra/own.test.ts";
for (const file of [skillTest, routable]) {
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
writeFileSync(path.join(cwd, file), "export {};\n");
}
expect(
resolveChangedNodeTestTargets([skillTest, routable], { cwd, selectionMode: "aggressive" }),
).toEqual([routable]);
});
it.each(["aggressive", "full"] as const)(
"routes skill node:test and Python files through their Vitest owner in %s mode",
(selectionMode) => {
const cwd = tempDirs.make("node-selection-routable-");
// Skill scripts carry node:test and Python files that no Vitest config owns.
const unownedSkillTest = ".agents/skills/example/scripts/driver.test.mjs";
const skillTest = ".agents/skills/telegram-e2e-userbot/scripts/driver.test.mjs";
const skillPythonTest = ".agents/skills/telegram-e2e-userbot/scripts/driver.test.py";
const wrapper = "test/scripts/telegram-e2e-userbot-skill.test.ts";
const routable = "src/infra/own.test.ts";
for (const file of [unownedSkillTest, skillTest, skillPythonTest, wrapper, routable]) {
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
writeFileSync(path.join(cwd, file), "export {};\n");
}
expect(resolveChangedNodeTestTargets([skillTest], { cwd, selectionMode })).toEqual([wrapper]);
expect(
resolveChangedNodeTestTargets([unownedSkillTest, skillPythonTest, routable], {
cwd,
selectionMode,
}),
).toEqual([routable, wrapper]);
},
);
it("bounds protected regressions to nearby consumers and restores area coverage in full mode", () => {
const cwd = tempDirs.make("node-selection-");

View file

@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, matchesGlob } from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@ -2055,6 +2055,32 @@ describe("scripts/lib/ci-node-test-plan.mts", () => {
}
});
it("runs Telegram skill script changes, including test-only edits, through the skill wrapper", () => {
const wrapper = "test/scripts/telegram-e2e-userbot-skill.test.ts";
const scriptsDir = ".agents/skills/telegram-e2e-userbot/scripts";
const scripts = readdirSync(scriptsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && !entry.name.startsWith("."))
.map((entry) => `${scriptsDir}/${entry.name}`);
expect(scripts.filter((file) => /\.test\.(?:mjs|py)$/u.test(file)).length).toBeGreaterThan(0);
for (const changedPath of scripts) {
expect(resolvePolicyTestTargets([changedPath]), changedPath).toContain(wrapper);
}
const changedTest = `${scriptsDir}/telegram-run-composition.test.mjs`;
const shards = expectDefined(
createChangedNodeTestShards([changedTest], { selectionMode: "aggressive" }),
"skill test plan",
);
const groups = shards.flatMap((shard) => shard.groups ?? []);
const selected = [
...shards.flatMap((shard) => shard.targets ?? []),
...groups.flatMap((group) => group.includePatterns ?? []),
];
expect(selected).not.toContain(changedTest);
const owners = groups.filter((group) => group.includePatterns?.includes(wrapper));
expect(owners).toHaveLength(1);
expect(owners[0]?.configs).toEqual(["test/vitest/vitest.tooling.config.ts"]);
});
it("matches policy owners with literal and native glob semantics", () => {
const changedPath = "ui/src/styles/base.css";
expect(isPolicyTestOwnedPath(changedPath)).toBe(true);