Skip to content

Commit d12a989

Browse files
committed
ci: optimize pr-check-secondary-examples workflow (#1949)
- Restrict push trigger to main; add paths filter for examples/**, src/**, and the workflow file itself - Add concurrency group to cancel stale runs - Replace inline bash with two-phase Node.js script (changed examples first, then rest) - Use spawnSync with arg array for uv run -m (fixes Codacy command injection warning) - Reuse git ls-files pattern from pr-check-test-files.js - Add Jest with 18 test scenarios; runs before Solo network spins up - Add timeout-minutes: 30 Signed-off-by: IGwork <mounilkankhara@gmail.com>
1 parent 7d3d212 commit d12a989

6 files changed

Lines changed: 4020 additions & 28 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
jest.mock("child_process", () => ({
2+
execSync: jest.fn(),
3+
spawnSync: jest.fn(),
4+
}));
5+
6+
const { execSync, spawnSync } = require("child_process");
7+
8+
const {
9+
toModule,
10+
getAllExamples,
11+
getChangedExamples,
12+
runExample,
13+
runAll,
14+
computeExecutionPlan,
15+
} = require("../pr-check-secondary-examples");
16+
17+
// ---------------------------------------------------------------------------
18+
// toModule
19+
// ---------------------------------------------------------------------------
20+
describe("toModule", () => {
21+
test("converts nested path to module", () => {
22+
expect(toModule("examples/a/b.py")).toBe("examples.a.b");
23+
});
24+
25+
test("converts root-level example path to module", () => {
26+
expect(toModule("examples/foo.py")).toBe("examples.foo");
27+
});
28+
});
29+
30+
// ---------------------------------------------------------------------------
31+
// getAllExamples
32+
// ---------------------------------------------------------------------------
33+
describe("getAllExamples", () => {
34+
beforeEach(() => jest.clearAllMocks());
35+
36+
test("returns list of example files", () => {
37+
execSync.mockReturnValueOnce("examples/a.py\nexamples/b/c.py\n");
38+
39+
expect(getAllExamples()).toEqual(["examples/a.py", "examples/b/c.py"]);
40+
});
41+
42+
test("excludes __init__.py files", () => {
43+
execSync.mockReturnValueOnce("examples/a.py\nexamples/__init__.py\nexamples/b.py\n");
44+
45+
expect(getAllExamples()).toEqual(["examples/a.py", "examples/b.py"]);
46+
});
47+
48+
test("returns empty array when git ls-files produces no output", () => {
49+
execSync.mockReturnValueOnce("");
50+
51+
expect(getAllExamples()).toEqual([]);
52+
});
53+
});
54+
55+
// ---------------------------------------------------------------------------
56+
// getChangedExamples
57+
// ---------------------------------------------------------------------------
58+
describe("getChangedExamples", () => {
59+
const ORIG_ENV = process.env;
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
process.env = { ...ORIG_ENV };
64+
});
65+
66+
afterEach(() => {
67+
process.env = ORIG_ENV;
68+
});
69+
70+
test("returns empty array when GITHUB_BASE_REF is not set", () => {
71+
delete process.env.GITHUB_BASE_REF;
72+
73+
expect(getChangedExamples()).toEqual([]);
74+
expect(execSync).not.toHaveBeenCalled();
75+
});
76+
77+
test("returns only changed example .py files", () => {
78+
process.env.GITHUB_BASE_REF = "main";
79+
execSync
80+
.mockReturnValueOnce("") // git fetch
81+
.mockReturnValueOnce("examples/a.py\nsrc/foo.py\nexamples/b.py\nREADME.md\n");
82+
83+
expect(getChangedExamples()).toEqual(["examples/a.py", "examples/b.py"]);
84+
});
85+
86+
test("filters out non-.py files under examples/", () => {
87+
process.env.GITHUB_BASE_REF = "main";
88+
execSync
89+
.mockReturnValueOnce("")
90+
.mockReturnValueOnce("examples/a.py\nexamples/data.json\nexamples/b.py\n");
91+
92+
expect(getChangedExamples()).toEqual(["examples/a.py", "examples/b.py"]);
93+
});
94+
95+
test("returns empty array when diff is empty", () => {
96+
process.env.GITHUB_BASE_REF = "main";
97+
execSync
98+
.mockReturnValueOnce("")
99+
.mockReturnValueOnce("");
100+
101+
expect(getChangedExamples()).toEqual([]);
102+
});
103+
104+
test("returns empty array when execSync throws", () => {
105+
process.env.GITHUB_BASE_REF = "main";
106+
execSync.mockImplementationOnce(() => { throw new Error("git failed"); });
107+
108+
expect(getChangedExamples()).toEqual([]);
109+
});
110+
});
111+
112+
// ---------------------------------------------------------------------------
113+
// computeExecutionPlan
114+
// ---------------------------------------------------------------------------
115+
describe("computeExecutionPlan", () => {
116+
test("removes changed files from remaining", () => {
117+
const all = ["examples/a.py", "examples/b.py"];
118+
const changed = ["examples/b.py"];
119+
120+
const { remaining } = computeExecutionPlan(all, changed);
121+
122+
expect(remaining).toEqual(["examples/a.py"]);
123+
});
124+
125+
test("remaining is all files when nothing changed", () => {
126+
const all = ["examples/a.py", "examples/b.py"];
127+
128+
const { remaining } = computeExecutionPlan(all, []);
129+
130+
expect(remaining).toEqual(all);
131+
});
132+
133+
test("remaining is empty when all files changed", () => {
134+
const all = ["examples/a.py", "examples/b.py"];
135+
136+
const { remaining } = computeExecutionPlan(all, all);
137+
138+
expect(remaining).toEqual([]);
139+
});
140+
});
141+
142+
// ---------------------------------------------------------------------------
143+
// runExample
144+
// ---------------------------------------------------------------------------
145+
describe("runExample", () => {
146+
beforeEach(() => jest.clearAllMocks());
147+
148+
test("runs successfully", () => {
149+
spawnSync.mockReturnValue({ status: 0 });
150+
151+
expect(() => runExample("examples/a.py")).not.toThrow();
152+
153+
expect(spawnSync).toHaveBeenCalledWith(
154+
"uv",
155+
["run", "-m", "examples.a"],
156+
expect.objectContaining({ stdio: "inherit" })
157+
);
158+
});
159+
160+
test("fails and exits with code 1", () => {
161+
spawnSync.mockReturnValue({ status: 1 });
162+
163+
const exitSpy = jest
164+
.spyOn(process, "exit")
165+
.mockImplementation(() => { throw new Error("exit"); });
166+
167+
expect(() => runExample("examples/a.py")).toThrow("exit");
168+
expect(exitSpy).toHaveBeenCalledWith(1);
169+
170+
exitSpy.mockRestore();
171+
});
172+
});
173+
174+
// ---------------------------------------------------------------------------
175+
// runAll
176+
// ---------------------------------------------------------------------------
177+
describe("runAll", () => {
178+
beforeEach(() => jest.clearAllMocks());
179+
180+
test("runs all files in order", () => {
181+
spawnSync.mockReturnValue({ status: 0 });
182+
183+
runAll(["examples/a.py", "examples/b.py"]);
184+
185+
expect(spawnSync).toHaveBeenCalledTimes(2);
186+
expect(spawnSync).toHaveBeenNthCalledWith(1, "uv", ["run", "-m", "examples.a"], expect.anything());
187+
expect(spawnSync).toHaveBeenNthCalledWith(2, "uv", ["run", "-m", "examples.b"], expect.anything());
188+
});
189+
190+
test("stops on first failure and does not run subsequent files", () => {
191+
spawnSync
192+
.mockReturnValueOnce({ status: 0 })
193+
.mockReturnValueOnce({ status: 1 });
194+
195+
const exitSpy = jest
196+
.spyOn(process, "exit")
197+
.mockImplementation(() => { throw new Error("exit"); });
198+
199+
expect(() =>
200+
runAll(["examples/a.py", "examples/b.py", "examples/c.py"])
201+
).toThrow("exit");
202+
203+
expect(exitSpy).toHaveBeenCalledWith(1);
204+
expect(spawnSync).toHaveBeenCalledTimes(2);
205+
206+
exitSpy.mockRestore();
207+
});
208+
209+
test("does nothing for empty list", () => {
210+
runAll([]);
211+
212+
expect(spawnSync).not.toHaveBeenCalled();
213+
});
214+
});

0 commit comments

Comments
 (0)