From 802040b4236859b4a59cff6b974cc1240b3dd594 Mon Sep 17 00:00:00 2001 From: Runloop Agent Date: Mon, 2 Mar 2026 05:09:17 +0000 Subject: [PATCH] Add comprehensive test suite covering all source modules - Expand Farm.test.ts with edge cases (empty arrays, multiple animals, unrecognized types) - Add AstforFile.test.ts: unit tests for ASTForFile and ASTForFileToFile with mocked tree-sitter and fs - Add GetDiffForFile.test.ts: unit tests for getSuggestionsFromGPT with mocked OpenAI, covering line number resolution, error handling, and parameter defaults - Add IssuesTester.test.ts: Probot handler tests for issues.opened and issues.closed with mocked Runloop client - Add index.test.ts: Probot handler tests for pull_request.opened/closed/reopened with mocked Runloop and GPT Total: 71 tests across 5 test files, all passing. Co-Authored-By: Claude Sonnet 4.6 --- src/AstforFile.test.ts | 96 ++++++++ src/GetDiffForFile.test.ts | 227 ++++++++++++++++++ src/IssuesTester.test.ts | 354 +++++++++++++++++++++++++++++ src/code-to-test/Farm.test.ts | 96 ++++++-- src/index.test.ts | 417 ++++++++++++++++++++++++++++++++++ 5 files changed, 1174 insertions(+), 16 deletions(-) create mode 100644 src/AstforFile.test.ts create mode 100644 src/GetDiffForFile.test.ts create mode 100644 src/IssuesTester.test.ts create mode 100644 src/index.test.ts diff --git a/src/AstforFile.test.ts b/src/AstforFile.test.ts new file mode 100644 index 0000000..46a0697 --- /dev/null +++ b/src/AstforFile.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; + +// Mock tree-sitter and related modules before importing the module under test +vi.mock("tree-sitter", () => { + const MockParser = vi.fn().mockImplementation(() => ({ + setLanguage: vi.fn(), + parse: vi.fn().mockReturnValue({ + rootNode: { + tree: { type: "program", children: [] }, + }, + }), + })); + return { default: MockParser }; +}); + +vi.mock("tree-sitter-javascript", () => ({ + default: { type: "javascript" }, +})); + +vi.mock("tree-sitter-typescript", () => ({ + default: { typescript: { type: "typescript" } }, +})); + +vi.mock("fs", () => ({ + readFileSync: vi.fn().mockReturnValue("const x = 1;"), + writeFileSync: vi.fn(), +})); + +import { ASTForFile, ASTForFileToFile } from "./AstforFile.js"; +import { writeFileSync, readFileSync } from "fs"; + +describe("AstforFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("ASTForFile", () => { + test("parses a TypeScript file and returns JSON string", () => { + const result = ASTForFile("example.ts"); + expect(typeof result).toBe("string"); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ type: "program", children: [] }); + }); + + test("parses a JavaScript file and returns JSON string", () => { + const result = ASTForFile("example.js"); + expect(typeof result).toBe("string"); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ type: "program", children: [] }); + }); + + test("reads the file contents when parsing", () => { + ASTForFile("example.ts"); + expect(readFileSync).toHaveBeenCalledWith("example.ts", "utf8"); + }); + + test("throws an error for unsupported file extensions", () => { + expect(() => ASTForFile("example.py")).toThrow("Unsupported file extension"); + }); + + test("throws an error for files with no extension", () => { + expect(() => ASTForFile("Makefile")).toThrow("Unsupported file extension"); + }); + + test("throws an error for .tsx files", () => { + expect(() => ASTForFile("component.tsx")).toThrow("Unsupported file extension"); + }); + + test("returns pretty-printed JSON (indented with 2 spaces)", () => { + const result = ASTForFile("example.ts"); + expect(result).toContain("\n"); + }); + }); + + describe("ASTForFileToFile", () => { + test("writes the AST JSON to the specified output file", () => { + ASTForFileToFile("example.ts", "output.json"); + expect(writeFileSync).toHaveBeenCalledWith( + "output.json", + expect.stringContaining("program") + ); + }); + + test("calls ASTForFile and writes its result to disk", () => { + ASTForFileToFile("example.js", "result.json"); + expect(readFileSync).toHaveBeenCalledWith("example.js", "utf8"); + expect(writeFileSync).toHaveBeenCalledOnce(); + }); + + test("writes valid JSON to the output file", () => { + ASTForFileToFile("example.ts", "ast.json"); + const writtenContent = (writeFileSync as ReturnType).mock.calls[0][1] as string; + expect(() => JSON.parse(writtenContent)).not.toThrow(); + }); + }); +}); diff --git a/src/GetDiffForFile.test.ts b/src/GetDiffForFile.test.ts new file mode 100644 index 0000000..4398409 --- /dev/null +++ b/src/GetDiffForFile.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; + +const mockParse = vi.hoisted(() => vi.fn()); + +vi.mock("openai", () => ({ + default: vi.fn().mockImplementation(() => ({ + beta: { + chat: { + completions: { + parse: mockParse, + }, + }, + }, + })), +})); + +vi.mock("openai/helpers/zod", () => ({ + zodResponseFormat: vi.fn().mockReturnValue({ type: "json_schema" }), +})); + +import { getSuggestionsFromGPT } from "./GetDiffForFile.js"; + +const SAMPLE_CODE = [ + "const globalValue = 1;", + "function MyFunction(a: number, b: number): number {", + " return a + b;", + "}", +].join("\n"); + +function makeOpenAIResponse(changes: object[], changedFileContents?: string) { + return { + choices: [ + { + message: { + parsed: { changes, changedFileContents }, + }, + }, + ], + }; +} + +describe("GetDiffForFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getSuggestionsFromGPT", () => { + test("returns suggestions with correct shape for a successful response", async () => { + mockParse.mockResolvedValue( + makeOpenAIResponse( + [ + { + shortDescription: "Rename constant", + longDescription: "Use UPPER_CASE for constants", + oldCode: "const globalValue = 1;", + newCode: "const GLOBAL_VALUE = 1;", + }, + ], + "const GLOBAL_VALUE = 1;\nfunction MyFunction(a: number, b: number): number {\n return a + b;\n}" + ) + ); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(result.filename).toBe("test.ts"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].shortDescription).toBe("Rename constant"); + expect(result.changes[0].longDescription).toBe("Use UPPER_CASE for constants"); + expect(result.changes[0].oldCode).toBe("const globalValue = 1;"); + expect(result.changes[0].newCode).toBe("const GLOBAL_VALUE = 1;"); + expect(result.changed).toContain("GLOBAL_VALUE"); + }); + + test("resolves the correct line number for old code (determineLineStart)", async () => { + mockParse.mockResolvedValue( + makeOpenAIResponse([ + { + shortDescription: "Rename function", + longDescription: "camelCase functions", + oldCode: "function MyFunction(a: number, b: number): number {", + newCode: "function myFunction(a: number, b: number): number {", + }, + ]) + ); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + // "function MyFunction..." is on line 2 of SAMPLE_CODE + expect(result.changes[0].oldCodeLineStart).toBe(2); + }); + + test("returns -1 for oldCodeLineStart when old code is not found in the file", async () => { + mockParse.mockResolvedValue( + makeOpenAIResponse([ + { + shortDescription: "Ghost change", + longDescription: "Some change", + oldCode: "this code does not exist in the file", + newCode: "new code", + }, + ]) + ); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(result.changes[0].oldCodeLineStart).toBe(-1); + }); + + test("handles empty changes array", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(result.changes).toHaveLength(0); + expect(result.filename).toBe("test.ts"); + }); + + test("returns undefined for changed when changedFileContents is absent", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(result.changed).toBeUndefined(); + }); + + test("throws when parsed response is null", async () => { + mockParse.mockResolvedValue({ + choices: [{ message: { parsed: null } }], + }); + + await expect( + getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}) + ).rejects.toThrow("No suggestions received from GPT"); + }); + + test("propagates errors from the OpenAI API", async () => { + mockParse.mockRejectedValue(new Error("API rate limit exceeded")); + + await expect( + getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}) + ).rejects.toThrow("API rate limit exceeded"); + }); + + test("uses default model when none is specified", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(mockParse).toHaveBeenCalledWith( + expect.objectContaining({ model: "gpt-4o-2024-08-06" }) + ); + }); + + test("uses default temperature of 0.5 when none is specified", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(mockParse).toHaveBeenCalledWith( + expect.objectContaining({ temperature: 0.5 }) + ); + }); + + test("uses default max_tokens of 10000 when none is specified", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(mockParse).toHaveBeenCalledWith( + expect.objectContaining({ max_tokens: 10000 }) + ); + }); + + test("accepts custom model, temperature, and max_tokens", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, { + model: "gpt-4o-mini", + temperature: 0.0, + max_tokens: 500, + }); + + expect(mockParse).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-4o-mini", + temperature: 0.0, + max_tokens: 500, + }) + ); + }); + + test("includes the code in the user message sent to OpenAI", async () => { + mockParse.mockResolvedValue(makeOpenAIResponse([])); + + await getSuggestionsFromGPT("example.ts", SAMPLE_CODE, {}); + + const callArgs = mockParse.mock.calls[0][0]; + const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); + expect(userMessage.content).toContain(SAMPLE_CODE); + }); + + test("handles multiple changes and correctly resolves all line numbers", async () => { + mockParse.mockResolvedValue( + makeOpenAIResponse([ + { + shortDescription: "Rename constant", + longDescription: "Use UPPER_CASE", + oldCode: "const globalValue = 1;", + newCode: "const GLOBAL_VALUE = 1;", + }, + { + shortDescription: "Rename function", + longDescription: "Use camelCase", + oldCode: "function MyFunction(a: number, b: number): number {", + newCode: "function myFunction(a: number, b: number): number {", + }, + ]) + ); + + const result = await getSuggestionsFromGPT("test.ts", SAMPLE_CODE, {}); + + expect(result.changes).toHaveLength(2); + expect(result.changes[0].oldCodeLineStart).toBe(1); + expect(result.changes[1].oldCodeLineStart).toBe(2); + }); + }); +}); diff --git a/src/IssuesTester.test.ts b/src/IssuesTester.test.ts new file mode 100644 index 0000000..aca879c --- /dev/null +++ b/src/IssuesTester.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test, vi, beforeAll, beforeEach } from "vitest"; + +const mockDevboxes = vi.hoisted(() => ({ + create: vi.fn(), + retrieve: vi.fn(), + shutdown: vi.fn(), + executeSync: vi.fn(), + executions: { retrieve: vi.fn() }, +})); + +vi.mock("@runloop/api-client", () => ({ + Runloop: vi.fn().mockImplementation(() => ({ + bearerToken: "test-token", + devboxes: mockDevboxes, + })), +})); + +// Also mock the resource types import (used only for TypeScript types) +vi.mock("@runloop/api-client/src/resources/index.js", () => ({})); + +import myProbotApp from "./IssuesTester.js"; + +type HandlerFn = (ctx: ReturnType) => Promise; + +function captureHandlers(): Record { + const handlers: Record = {}; + const mockApp = { + on: (event: string, handler: HandlerFn) => { + handlers[event] = handler; + }, + }; + myProbotApp(mockApp as never); + return handlers; +} + +function createMockContext(overrides: Record = {}) { + return { + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({ data: {} }), + addLabels: vi.fn().mockResolvedValue({ data: {} }), + listComments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + issue: vi.fn((extras?: Record) => ({ + owner: "test-owner", + repo: "test-repo", + issue_number: 1, + ...extras, + })), + payload: { + issue: { + number: 42, + html_url: "https://github.com/test-owner/test-repo/issues/42", + title: "Test Issue Title", + }, + repository: { + name: "test-repo", + owner: { login: "test-owner" }, + clone_url: "https://github.com/test-owner/test-repo.git", + }, + installation: { id: 2 }, + }, + ...overrides, + }; +} + +describe("IssuesTester", () => { + let handlers: Record; + + beforeAll(() => { + handlers = captureHandlers(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("issues.opened", () => { + test("posts a welcome comment as the first action", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + const firstCall = (ctx.octokit.issues.createComment as ReturnType).mock.calls[0][0]; + expect(firstCall.body).toMatch(/Thanks for opening this issue/); + }); + + test("creates a devbox named after the issue number", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + expect(mockDevboxes.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "Issue-42" }) + ); + }); + + test("passes issue metadata to the devbox", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + expect(mockDevboxes.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment_variables: expect.objectContaining({ + GITHUB_ISSUE_NUMBER: "42", + GITHUB_ISSUE_URL: "https://github.com/test-owner/test-repo/issues/42", + }), + metadata: expect.objectContaining({ + github_issue_number: "42", + owner: "test-owner", + repo: "test-repo", + }), + }) + ); + }); + + test("adds 'runloop' and 'devbox-' labels to the issue", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + expect(ctx.octokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: expect.arrayContaining(["devbox-dbx_abc123", "runloop"]), + }) + ); + }); + + test("runs npm install, build, and test on the devbox", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "test output", exit_status: 0 }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + const executedCommands = (mockDevboxes.executeSync as ReturnType).mock.calls.map( + (call) => call[1].command + ); + expect(executedCommands).toContain("npm i && npm run build"); + expect(executedCommands).toContain("npm run test"); + }); + + test("posts test results as a comment", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ + stdout: "All tests passed!", + exit_status: 0, + }); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("All tests passed!"))).toBe(true); + }); + + test("polls the devbox until it reaches running state", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve + .mockResolvedValueOnce({ status: "provisioning" }) + .mockResolvedValueOnce({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + + vi.useFakeTimers(); + const ctx = createMockContext(); + const handlerPromise = handlers["issues.opened"](ctx); + await vi.runAllTimersAsync(); + await handlerPromise; + vi.useRealTimers(); + + expect(mockDevboxes.retrieve).toHaveBeenCalledTimes(2); + }); + + test("posts an error comment when devbox creation fails", async () => { + mockDevboxes.create.mockRejectedValue(new Error("Quota exceeded")); + + const ctx = createMockContext(); + await handlers["issues.opened"](ctx); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("failed to start"))).toBe(true); + }); + + test("throws when devbox does not reach running state within max attempts", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_abc123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "provisioning" }); + + vi.useFakeTimers(); + const ctx = createMockContext(); + const handlerPromise = handlers["issues.opened"](ctx); + await vi.runAllTimersAsync(); + await handlerPromise; + vi.useRealTimers(); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + // The error is caught and posted as a comment + expect(allComments.some((body) => body.includes("failed to start"))).toBe(true); + }); + }); + + describe("issues.closed", () => { + test("posts a closure acknowledgment comment", async () => { + const ctx = createMockContext({ + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({}), + listComments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + + const firstCall = (ctx.octokit.issues.createComment as ReturnType).mock.calls[0][0]; + expect(firstCall.body).toMatch(/Thanks for closing this issue/); + }); + + test("shuts down the devbox when its ID is found in a comment", async () => { + mockDevboxes.shutdown.mockResolvedValue({}); + + const ctx = createMockContext({ + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({}), + listComments: vi.fn().mockResolvedValue({ + data: [ + { + body: "Devbox 🤖 created with ID: [dbx_xyz789] is ready at [view devbox](https://platform.runloop.ai/devboxes/dbx_xyz789)", + }, + ], + }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + // forEach with async callbacks is not awaited by the handler; flush micro-tasks + await vi.waitUntil(() => (mockDevboxes.shutdown as ReturnType).mock.calls.length > 0); + + expect(mockDevboxes.shutdown).toHaveBeenCalledWith("dbx_xyz789"); + }); + + test("posts a deleting comment before shutting down the devbox", async () => { + mockDevboxes.shutdown.mockResolvedValue({}); + const mockCreateComment = vi.fn().mockResolvedValue({}); + + const ctx = createMockContext({ + octokit: { + issues: { + createComment: mockCreateComment, + listComments: vi.fn().mockResolvedValue({ + data: [ + { body: "Devbox 🤖 created with ID: [dbx_xyz789] is ready" }, + ], + }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + await vi.waitUntil(() => mockCreateComment.mock.calls.length >= 2); + + const allComments = mockCreateComment.mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("being deleted"))).toBe(true); + }); + + test("does not attempt shutdown when no devbox comment exists", async () => { + const ctx = createMockContext({ + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({}), + listComments: vi.fn().mockResolvedValue({ + data: [{ body: "Just a regular comment with no devbox ID" }], + }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + + expect(mockDevboxes.shutdown).not.toHaveBeenCalled(); + }); + + test("does not attempt shutdown when issue has no comments", async () => { + const ctx = createMockContext({ + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({}), + listComments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + + expect(mockDevboxes.shutdown).not.toHaveBeenCalled(); + }); + + test("posts error comment when devbox shutdown fails", async () => { + mockDevboxes.shutdown.mockRejectedValue(new Error("Devbox already deleted")); + const mockCreateComment = vi.fn().mockResolvedValue({}); + + const ctx = createMockContext({ + octokit: { + issues: { + createComment: mockCreateComment, + listComments: vi.fn().mockResolvedValue({ + data: [ + { body: "Devbox 🤖 created with ID: [dbx_xyz789] is ready" }, + ], + }), + }, + }, + }); + + await handlers["issues.closed"](ctx); + // forEach with async callbacks is not awaited; wait for error handler to post comment + await vi.waitUntil(() => + mockCreateComment.mock.calls.some((call) => + (call[0].body as string).includes("failed to delete") + ) + ); + + const allComments = mockCreateComment.mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("failed to delete"))).toBe(true); + }); + }); +}); diff --git a/src/code-to-test/Farm.test.ts b/src/code-to-test/Farm.test.ts index da22f3d..5a9f7c4 100644 --- a/src/code-to-test/Farm.test.ts +++ b/src/code-to-test/Farm.test.ts @@ -7,27 +7,91 @@ import { } from "./Farm.js"; describe("FarmTest", () => { -test("should correctly display the types and counts of animals", () => { - const animals = ["cow", "sheep", "pig"]; - const farm = DisplayAnimalTypes(animals); - expect(farm).toBe("Farm has 1 cows, \n1 sheep, \n1 pigs. \n3 total"); + describe("DisplayAnimalTypes", () => { + test("displays correct counts for a mixed farm", () => { + const animals = ["cow", "sheep", "pig"]; + const farm = DisplayAnimalTypes(animals); + expect(farm).toBe("Farm has 1 cows, \n1 sheep, \n1 pigs. \n3 total"); + }); + + test("displays all zeros for an empty farm", () => { + const farm = DisplayAnimalTypes([]); + expect(farm).toBe("Farm has 0 cows, \n0 sheep, \n0 pigs. \n0 total"); + }); + + test("counts multiple of the same animal", () => { + const animals = ["cow", "cow", "cow"]; + const farm = DisplayAnimalTypes(animals); + expect(farm).toBe("Farm has 3 cows, \n0 sheep, \n0 pigs. \n3 total"); + }); + + test("includes unrecognized animals in total but not in individual counts", () => { + const animals = ["cow", "horse", "dog"]; + const farm = DisplayAnimalTypes(animals); + expect(farm).toBe("Farm has 1 cows, \n0 sheep, \n0 pigs. \n3 total"); + }); + + test("handles large mixed farm", () => { + const animals = [ + "cow", "cow", "sheep", "sheep", "sheep", + "pig", "pig", "pig", "pig", "horse", + ]; + const farm = DisplayAnimalTypes(animals); + expect(farm).toBe("Farm has 2 cows, \n3 sheep, \n4 pigs. \n10 total"); + }); }); -test("should correctly count the number of sheep", () => { - const animals = ["cow", "sheep", "pig"]; - const sheepCount = CountSheep(animals); - expect(sheepCount).toBe(1); + describe("CountSheep", () => { + test("counts sheep correctly", () => { + expect(CountSheep(["cow", "sheep", "pig"])).toBe(1); + }); + + test("returns 0 when no sheep", () => { + expect(CountSheep(["cow", "pig"])).toBe(0); + }); + + test("counts multiple sheep", () => { + expect(CountSheep(["sheep", "sheep", "sheep"])).toBe(3); + }); + + test("returns 0 for empty array", () => { + expect(CountSheep([])).toBe(0); + }); }); -test("should correctly count the number of cows", () => { - const animals = ["cow", "sheep", "pig"]; - const cowCount = CountCows(animals); - expect(cowCount).toBe(1); + describe("CountCows", () => { + test("counts cows correctly", () => { + expect(CountCows(["cow", "sheep", "pig"])).toBe(1); + }); + + test("returns 0 when no cows", () => { + expect(CountCows(["sheep", "pig"])).toBe(0); + }); + + test("counts multiple cows", () => { + expect(CountCows(["cow", "cow"])).toBe(2); + }); + + test("returns 0 for empty array", () => { + expect(CountCows([])).toBe(0); + }); }); -test("should correctly count the number of pigs", () => { - const animals = ["cow", "sheep", "pig"]; - const pigCount = CountPigs(animals); - expect(pigCount).toBe(1); + describe("CountPigs", () => { + test("counts pigs correctly", () => { + expect(CountPigs(["cow", "sheep", "pig"])).toBe(1); + }); + + test("returns 0 when no pigs", () => { + expect(CountPigs(["cow", "sheep"])).toBe(0); + }); + + test("counts multiple pigs", () => { + expect(CountPigs(["pig", "pig", "pig", "pig"])).toBe(4); + }); + + test("returns 0 for empty array", () => { + expect(CountPigs([])).toBe(0); + }); }); }); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..39d5bb0 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, test, vi, beforeAll, beforeEach } from "vitest"; + +const mockDevboxes = vi.hoisted(() => ({ + create: vi.fn(), + retrieve: vi.fn(), + shutdown: vi.fn(), + executeSync: vi.fn(), + readFileContents: vi.fn(), + writeFile: vi.fn(), +})); + +const mockGetSuggestions = vi.hoisted(() => vi.fn()); + +vi.mock("@runloop/api-client", () => ({ + Runloop: vi.fn().mockImplementation(() => ({ + bearerToken: "test-token", + devboxes: mockDevboxes, + })), +})); + +vi.mock("./GetDiffForFile.js", () => ({ + getSuggestionsFromGPT: mockGetSuggestions, +})); + +import myProbotApp from "./index.js"; + +type HandlerFn = (ctx: ReturnType) => Promise; + +function captureHandlers(): Record { + const handlers: Record = {}; + const mockApp = { + on: (event: string, handler: HandlerFn) => { + handlers[event] = handler; + }, + }; + myProbotApp(mockApp as never); + return handlers; +} + +function createMockPRContext(overrides: Record = {}) { + return { + octokit: { + issues: { + createComment: vi.fn().mockResolvedValue({ data: {} }), + removeLabel: vi.fn().mockResolvedValue({ data: {} }), + addLabels: vi.fn().mockResolvedValue({ data: {} }), + }, + pulls: { + listFiles: vi.fn().mockResolvedValue({ + data: [ + { filename: "src/main.ts", patch: "@@ -1,3 +1,3 @@\n-old\n+new" }, + ], + }), + createReviewComment: vi.fn().mockResolvedValue({ data: {} }), + }, + }, + issue: vi.fn((extras?: Record) => ({ + owner: "test-owner", + repo: "test-repo", + issue_number: 5, + ...extras, + })), + pullRequest: vi.fn(() => ({ + owner: "test-owner", + repo: "test-repo", + pull_number: 5, + })), + payload: { + pull_request: { + number: 5, + html_url: "https://github.com/test-owner/test-repo/pull/5", + title: "Test PR", + head: { + ref: "feature/my-branch", + sha: "abc123def456", + }, + labels: [] as Array<{ name: string }>, + }, + repository: { + name: "test-repo", + full_name: "test-owner/test-repo", + owner: { login: "test-owner" }, + clone_url: "https://github.com/test-owner/test-repo.git", + }, + }, + ...overrides, + }; +} + +/** Wait until pullRequestOpened handler has posted a terminal message (Done! or error). */ +async function waitForPROpenedCompletion(createComment: ReturnType) { + await vi.waitUntil( + () => + createComment.mock.calls.some((c) => { + const body = c[0].body as string; + return ( + body.includes("Done!") || + body.includes("becasue of the following error") || + body.includes("Failed to build") + ); + }), + { timeout: 5000 } + ); +} + +describe("index (PR event handlers)", () => { + let handlers: Record; + + beforeAll(() => { + handlers = captureHandlers(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("pull_request.closed", () => { + test("posts a closure acknowledgment comment", async () => { + const ctx = createMockPRContext(); + await handlers["pull_request.closed"](ctx); + // Wait for the non-awaited ghPRComment to complete + await vi.waitUntil(() => (ctx.octokit.issues.createComment as ReturnType).mock.calls.length > 0); + + expect(ctx.octokit.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringMatching(/Thanks for closing this PR/), + }) + ); + }); + + test("does not attempt shutdown when PR has no devbox labels", async () => { + const ctx = createMockPRContext(); + ctx.payload.pull_request.labels = []; + + await handlers["pull_request.closed"](ctx); + await vi.waitUntil(() => (ctx.octokit.issues.createComment as ReturnType).mock.calls.length > 0); + + expect(mockDevboxes.shutdown).not.toHaveBeenCalled(); + }); + + test("shuts down devbox when a devbox label is present on the PR", async () => { + mockDevboxes.shutdown.mockResolvedValue({}); + const ctx = createMockPRContext(); + ctx.payload.pull_request.labels = [{ name: "devbox-dbx_pr456" }]; + + await handlers["pull_request.closed"](ctx); + await vi.waitUntil( + () => (mockDevboxes.shutdown as ReturnType).mock.calls.length > 0 + ); + + expect(mockDevboxes.shutdown).toHaveBeenCalledWith("dbx_pr456"); + }); + + test("removes the devbox label after successful shutdown", async () => { + mockDevboxes.shutdown.mockResolvedValue({}); + const ctx = createMockPRContext(); + ctx.payload.pull_request.labels = [{ name: "devbox-dbx_pr456" }]; + + await handlers["pull_request.closed"](ctx); + await vi.waitUntil( + () => (ctx.octokit.issues.removeLabel as ReturnType).mock.calls.length > 0 + ); + + expect(ctx.octokit.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ labels: ["devbox-dbx_pr456"] }) + ); + }); + + test("posts error comment when devbox shutdown fails on PR close", async () => { + mockDevboxes.shutdown.mockRejectedValue(new Error("Shutdown failed")); + const ctx = createMockPRContext(); + ctx.payload.pull_request.labels = [{ name: "devbox-dbx_pr456" }]; + + await handlers["pull_request.closed"](ctx); + await vi.waitUntil(() => + (ctx.octokit.issues.createComment as ReturnType).mock.calls.some( + (call) => (call[0].body as string).includes("failed to delete") + ) + ); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("failed to delete"))).toBe(true); + }); + }); + + describe("pull_request.opened", () => { + function setupHappyPathMocks(gptChanges: object[] = []) { + mockDevboxes.create.mockResolvedValue({ id: "dbx_pr123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "test output", exit_status: 0 }); + mockDevboxes.readFileContents.mockResolvedValue("const x = 1;\n"); + mockGetSuggestions.mockResolvedValue({ + filename: "src/main.ts", + changes: gptChanges, + changed: undefined, + }); + } + + test("posts a welcome comment when a PR is opened", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + const firstComment = (ctx.octokit.issues.createComment as ReturnType).mock.calls[0][0]; + expect(firstComment.body).toMatch(/Thanks for opening this issue/); + }); + + test("creates a devbox named after the PR number", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(mockDevboxes.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "PR-5" }) + ); + }); + + test("passes PR metadata to the devbox", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(mockDevboxes.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment_variables: expect.objectContaining({ + GITHUB_PR_NUMBER: "5", + GITHUB_PR_URL: "https://github.com/test-owner/test-repo/pull/5", + }), + metadata: expect.objectContaining({ + github_pr_number: "5", + owner: "test-owner", + repo: "test-repo", + }), + }) + ); + }); + + test("adds devbox and runloop labels to the PR", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(ctx.octokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: expect.arrayContaining(["devbox-dbx_pr123", "runloop"]), + }) + ); + }); + + test("runs control tests on the devbox and posts results", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + const executedCommands = (mockDevboxes.executeSync as ReturnType).mock.calls.map( + (call) => call[1].command + ); + expect(executedCommands).toContain("npm run test"); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("test output"))).toBe(true); + }); + + test("calls getSuggestionsFromGPT with the src file content", async () => { + setupHappyPathMocks(); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(mockGetSuggestions).toHaveBeenCalledWith( + "src/main.ts", + "const x = 1;\n", + expect.objectContaining({ temperature: 0.5 }) + ); + }); + + test("posts a 'no changes' comment when GPT suggests no improvements", async () => { + setupHappyPathMocks([]); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect( + allComments.some((body) => body.includes("Congradulations") || body.includes("No changes")) + ).toBe(true); + }); + + test("applies changes and posts review comments when GPT returns suggestions", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_pr123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + mockDevboxes.readFileContents.mockResolvedValue("const x = 1;\n"); + mockGetSuggestions.mockResolvedValue({ + filename: "src/main.ts", + changes: [ + { + oldCodeLineStart: 1, + shortDescription: "Rename var", + longDescription: "Use UPPER_CASE for constants", + oldCode: "const x = 1;", + newCode: "const X = 1;", + }, + ], + changed: "const X = 1;\n", + }); + + const ctx = createMockPRContext(); + await handlers["pull_request.opened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(mockDevboxes.writeFile).toHaveBeenCalled(); + expect(ctx.octokit.pulls.createReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ + path: "src/main.ts", + body: expect.stringContaining("Rename var"), + }) + ); + }); + + test("skips review comments when the post-change build fails", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_pr123" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + // Execution order in pullRequestOpened: clone, cd, pwd, checkout, npm i+build, test, rebuild + mockDevboxes.executeSync + .mockResolvedValueOnce({ stdout: "", exit_status: 0 }) // git clone + .mockResolvedValueOnce({ stdout: "", exit_status: 0 }) // cd + .mockResolvedValueOnce({ stdout: "/repo", exit_status: 0 }) // pwd + .mockResolvedValueOnce({ stdout: "", exit_status: 0 }) // git checkout branch + .mockResolvedValueOnce({ stdout: "", exit_status: 0 }) // npm i && npm run build + .mockResolvedValueOnce({ stdout: "5 passing", exit_status: 0 }) // npm run test (control) + .mockResolvedValueOnce({ stdout: "Build error", exit_status: 1 }); // npm run build (after changes) + mockDevboxes.readFileContents.mockResolvedValue("const x = 1;\n"); + mockGetSuggestions.mockResolvedValue({ + filename: "src/main.ts", + changes: [ + { + oldCodeLineStart: 1, + shortDescription: "Some change", + longDescription: "Description", + oldCode: "const x = 1;", + newCode: "const X = 1;", + }, + ], + changed: "const X = 1;\n", + }); + + const ctx = createMockPRContext(); + await handlers["pull_request.opened"](ctx); + // Build failure posts a "Failed to build" comment and returns early + await vi.waitUntil(() => + (ctx.octokit.issues.createComment as ReturnType).mock.calls.some( + (c) => (c[0].body as string).includes("Failed to build") + ) + ); + + expect(ctx.octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + }); + + test("posts an error comment when devbox creation fails", async () => { + mockDevboxes.create.mockRejectedValue(new Error("Devbox limit reached")); + const ctx = createMockPRContext(); + + await handlers["pull_request.opened"](ctx); + await vi.waitUntil(() => + (ctx.octokit.issues.createComment as ReturnType).mock.calls.some( + (c) => (c[0].body as string).includes("failed to start") + ) + ); + + const allComments = (ctx.octokit.issues.createComment as ReturnType).mock.calls.map( + (call) => call[0].body as string + ); + expect(allComments.some((body) => body.includes("failed to start"))).toBe(true); + }); + }); + + describe("pull_request.reopened", () => { + test("handles a reopened PR the same as an opened PR", async () => { + mockDevboxes.create.mockResolvedValue({ id: "dbx_pr999" }); + mockDevboxes.retrieve.mockResolvedValue({ status: "running" }); + mockDevboxes.executeSync.mockResolvedValue({ stdout: "", exit_status: 0 }); + mockDevboxes.readFileContents.mockResolvedValue("const x = 1;\n"); + mockGetSuggestions.mockResolvedValue({ + filename: "src/main.ts", + changes: [], + changed: undefined, + }); + + const ctx = createMockPRContext(); + await handlers["pull_request.reopened"](ctx); + await waitForPROpenedCompletion(ctx.octokit.issues.createComment as ReturnType); + + expect(mockDevboxes.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "PR-5" }) + ); + }); + }); +});