Skip to content

Commit 409988f

Browse files
committed
Merge remote-tracking branch 'origin/main' into revision-guard-draft-flow
2 parents b47afb8 + d02fb59 commit 409988f

72 files changed

Lines changed: 5756 additions & 206 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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(spawnSync).not.toHaveBeenCalled();
75+
});
76+
77+
test("returns only changed example .py files", () => {
78+
process.env.GITHUB_BASE_REF = "main";
79+
spawnSync
80+
.mockReturnValueOnce({ status: 0, stdout: "" }) // git fetch
81+
.mockReturnValueOnce({ status: 0, stdout: "" }) // git rev-parse --verify
82+
.mockReturnValueOnce({ status: 0, stdout: "examples/a.py\nsrc/foo.py\nexamples/b.py\nREADME.md\n" }); // git diff
83+
84+
expect(getChangedExamples()).toEqual(["examples/a.py", "examples/b.py"]);
85+
});
86+
87+
test("filters out non-.py files under examples/", () => {
88+
process.env.GITHUB_BASE_REF = "main";
89+
spawnSync
90+
.mockReturnValueOnce({ status: 0, stdout: "" })
91+
.mockReturnValueOnce({ status: 0, stdout: "" })
92+
.mockReturnValueOnce({ status: 0, stdout: "examples/a.py\nexamples/data.json\nexamples/b.py\n" });
93+
94+
expect(getChangedExamples()).toEqual(["examples/a.py", "examples/b.py"]);
95+
});
96+
97+
test("excludes __init__.py from changed examples", () => {
98+
process.env.GITHUB_BASE_REF = "main";
99+
spawnSync
100+
.mockReturnValueOnce({ status: 0, stdout: "" })
101+
.mockReturnValueOnce({ status: 0, stdout: "" })
102+
.mockReturnValueOnce({ status: 0, stdout: "examples/a.py\nexamples/__init__.py\nexamples/b.py\n" });
103+
104+
expect(getChangedExamples()).toEqual(["examples/a.py", "examples/b.py"]);
105+
});
106+
107+
test("returns empty array when diff is empty", () => {
108+
process.env.GITHUB_BASE_REF = "main";
109+
spawnSync
110+
.mockReturnValueOnce({ status: 0, stdout: "" })
111+
.mockReturnValueOnce({ status: 0, stdout: "" })
112+
.mockReturnValueOnce({ status: 0, stdout: "" });
113+
114+
expect(getChangedExamples()).toEqual([]);
115+
});
116+
117+
test("returns empty array when fetch fails", () => {
118+
process.env.GITHUB_BASE_REF = "main";
119+
spawnSync.mockReturnValueOnce({ status: 1, stdout: "" });
120+
121+
expect(getChangedExamples()).toEqual([]);
122+
});
123+
124+
test("returns empty array when spawnSync throws", () => {
125+
process.env.GITHUB_BASE_REF = "main";
126+
spawnSync.mockImplementationOnce(() => { throw new Error("git not found"); });
127+
128+
expect(getChangedExamples()).toEqual([]);
129+
});
130+
});
131+
132+
// ---------------------------------------------------------------------------
133+
// computeExecutionPlan
134+
// ---------------------------------------------------------------------------
135+
describe("computeExecutionPlan", () => {
136+
test("removes changed files from remaining", () => {
137+
const all = ["examples/a.py", "examples/b.py"];
138+
const changed = ["examples/b.py"];
139+
140+
const { remaining } = computeExecutionPlan(all, changed);
141+
142+
expect(remaining).toEqual(["examples/a.py"]);
143+
});
144+
145+
test("remaining is all files when nothing changed", () => {
146+
const all = ["examples/a.py", "examples/b.py"];
147+
148+
const { remaining } = computeExecutionPlan(all, []);
149+
150+
expect(remaining).toEqual(all);
151+
});
152+
153+
test("remaining is empty when all files changed", () => {
154+
const all = ["examples/a.py", "examples/b.py"];
155+
156+
const { remaining } = computeExecutionPlan(all, all);
157+
158+
expect(remaining).toEqual([]);
159+
});
160+
161+
test("excludes deleted/renamed files from phase-1 when not present in all", () => {
162+
const all = ["examples/a.py", "examples/b.py"];
163+
const changed = ["examples/b.py", "examples/deleted.py"];
164+
165+
const { changed: validChanged, remaining } = computeExecutionPlan(all, changed);
166+
167+
expect(validChanged).toEqual(["examples/b.py"]);
168+
expect(remaining).toEqual(["examples/a.py"]);
169+
});
170+
});
171+
172+
// ---------------------------------------------------------------------------
173+
// runExample
174+
// ---------------------------------------------------------------------------
175+
describe("runExample", () => {
176+
beforeEach(() => jest.clearAllMocks());
177+
178+
test("runs successfully", () => {
179+
spawnSync.mockReturnValue({ status: 0 });
180+
181+
expect(() => runExample("examples/a.py")).not.toThrow();
182+
183+
expect(spawnSync).toHaveBeenCalledWith(
184+
"uv",
185+
["run", "-m", "examples.a"],
186+
expect.objectContaining({ stdio: "inherit" })
187+
);
188+
});
189+
190+
test("fails and exits with code 1", () => {
191+
spawnSync.mockReturnValue({ status: 1 });
192+
193+
const exitSpy = jest
194+
.spyOn(process, "exit")
195+
.mockImplementation(() => { throw new Error("exit"); });
196+
197+
expect(() => runExample("examples/a.py")).toThrow("exit");
198+
expect(exitSpy).toHaveBeenCalledWith(1);
199+
200+
exitSpy.mockRestore();
201+
});
202+
});
203+
204+
// ---------------------------------------------------------------------------
205+
// runAll
206+
// ---------------------------------------------------------------------------
207+
describe("runAll", () => {
208+
beforeEach(() => jest.clearAllMocks());
209+
210+
test("runs all files in order", () => {
211+
spawnSync.mockReturnValue({ status: 0 });
212+
213+
runAll(["examples/a.py", "examples/b.py"]);
214+
215+
expect(spawnSync).toHaveBeenCalledTimes(2);
216+
expect(spawnSync).toHaveBeenNthCalledWith(1, "uv", ["run", "-m", "examples.a"], expect.anything());
217+
expect(spawnSync).toHaveBeenNthCalledWith(2, "uv", ["run", "-m", "examples.b"], expect.anything());
218+
});
219+
220+
test("stops on first failure and does not run subsequent files", () => {
221+
spawnSync
222+
.mockReturnValueOnce({ status: 0 })
223+
.mockReturnValueOnce({ status: 1 });
224+
225+
const exitSpy = jest
226+
.spyOn(process, "exit")
227+
.mockImplementation(() => { throw new Error("exit"); });
228+
229+
expect(() =>
230+
runAll(["examples/a.py", "examples/b.py", "examples/c.py"])
231+
).toThrow("exit");
232+
233+
expect(exitSpy).toHaveBeenCalledWith(1);
234+
expect(spawnSync).toHaveBeenCalledTimes(2);
235+
236+
exitSpy.mockRestore();
237+
});
238+
239+
test("does nothing for empty list", () => {
240+
runAll([]);
241+
242+
expect(spawnSync).not.toHaveBeenCalled();
243+
});
244+
});

0 commit comments

Comments
 (0)