From 4996a63bcc36591358d013aa8305009688fb5b0f Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Tue, 16 Dec 2025 22:04:47 -0500 Subject: [PATCH] feat: improve Playwright configuration and enhance error handling in CreatePRDialog - Updated Playwright configuration to always reuse existing servers, improving test efficiency. - Enhanced CreatePRDialog to handle null browser URLs gracefully, ensuring better user experience during PR creation failures. - Added new unit tests for app specification format and automaker paths, improving test coverage and reliability. - Introduced tests for file system utilities and logger functionality, ensuring robust error handling and logging behavior. - Implemented comprehensive tests for SDK options and dev server service, enhancing overall test stability and maintainability. --- apps/app/playwright.config.ts | 4 +- .../board-view/dialogs/create-pr-dialog.tsx | 4 +- .../tests/unit/lib/app-spec-format.test.ts | 57 +++ .../tests/unit/lib/automaker-paths.test.ts | 132 ++++++ apps/server/tests/unit/lib/fs-utils.test.ts | 113 +++++ apps/server/tests/unit/lib/logger.test.ts | 119 +++++ .../server/tests/unit/lib/sdk-options.test.ts | 238 ++++++++++ .../unit/services/dev-server-service.test.ts | 433 ++++++++++++++++++ 8 files changed, 1096 insertions(+), 4 deletions(-) create mode 100644 apps/server/tests/unit/lib/app-spec-format.test.ts create mode 100644 apps/server/tests/unit/lib/automaker-paths.test.ts create mode 100644 apps/server/tests/unit/lib/fs-utils.test.ts create mode 100644 apps/server/tests/unit/lib/logger.test.ts create mode 100644 apps/server/tests/unit/lib/sdk-options.test.ts create mode 100644 apps/server/tests/unit/services/dev-server-service.test.ts diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index 8e653ad1..26f06499 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ { command: `cd ../server && npm run dev`, url: `http://localhost:${serverPort}/api/health`, - reuseExistingServer: !process.env.CI, + reuseExistingServer: true, timeout: 60000, env: { ...process.env, @@ -47,7 +47,7 @@ export default defineConfig({ { command: `npx next dev -p ${port}`, url: `http://localhost:${port}`, - reuseExistingServer: !process.env.CI, + reuseExistingServer: true, timeout: 120000, env: { ...process.env, diff --git a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 361b4f65..6c6a2048 100644 --- a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -115,7 +115,7 @@ export function CreatePRDialog({ if (!result.result.prCreated && hasBrowserUrl) { // If gh CLI is not available, show browser fallback UI if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) { - setBrowserUrl(result.result.browserUrl); + setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); toast.success("Branch pushed", { description: result.result.committed @@ -140,7 +140,7 @@ export function CreatePRDialog({ } // Show error but also provide browser option - setBrowserUrl(result.result.browserUrl); + setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); toast.error("PR creation failed", { description: errorMessage, diff --git a/apps/server/tests/unit/lib/app-spec-format.test.ts b/apps/server/tests/unit/lib/app-spec-format.test.ts new file mode 100644 index 00000000..a2fdd11e --- /dev/null +++ b/apps/server/tests/unit/lib/app-spec-format.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { + APP_SPEC_XML_FORMAT, + getAppSpecFormatInstruction, +} from "@/lib/app-spec-format.js"; + +describe("app-spec-format.ts", () => { + describe("APP_SPEC_XML_FORMAT", () => { + it("should export a non-empty string constant", () => { + expect(typeof APP_SPEC_XML_FORMAT).toBe("string"); + expect(APP_SPEC_XML_FORMAT.length).toBeGreaterThan(0); + }); + + it("should contain XML format documentation", () => { + expect(APP_SPEC_XML_FORMAT).toContain(""); + expect(APP_SPEC_XML_FORMAT).toContain(""); + expect(APP_SPEC_XML_FORMAT).toContain(""); + expect(APP_SPEC_XML_FORMAT).toContain(""); + expect(APP_SPEC_XML_FORMAT).toContain(""); + expect(APP_SPEC_XML_FORMAT).toContain(""); + }); + + it("should contain XML escaping instructions", () => { + expect(APP_SPEC_XML_FORMAT).toContain("<"); + expect(APP_SPEC_XML_FORMAT).toContain(">"); + expect(APP_SPEC_XML_FORMAT).toContain("&"); + }); + }); + + describe("getAppSpecFormatInstruction", () => { + it("should return a string containing the XML format", () => { + const instruction = getAppSpecFormatInstruction(); + expect(typeof instruction).toBe("string"); + expect(instruction).toContain(APP_SPEC_XML_FORMAT); + }); + + it("should contain critical formatting requirements", () => { + const instruction = getAppSpecFormatInstruction(); + expect(instruction).toContain("CRITICAL FORMATTING REQUIREMENTS"); + expect(instruction).toContain(""); + expect(instruction).toContain(""); + }); + + it("should contain verification instructions", () => { + const instruction = getAppSpecFormatInstruction(); + expect(instruction).toContain("VERIFICATION"); + expect(instruction).toContain("exactly one root XML element"); + }); + + it("should instruct not to use markdown", () => { + const instruction = getAppSpecFormatInstruction(); + expect(instruction).toContain("Do NOT use markdown"); + expect(instruction).toContain("no # headers"); + expect(instruction).toContain("no **bold**"); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/automaker-paths.test.ts b/apps/server/tests/unit/lib/automaker-paths.test.ts new file mode 100644 index 00000000..e8720663 --- /dev/null +++ b/apps/server/tests/unit/lib/automaker-paths.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import path from "path"; +import fs from "fs/promises"; +import os from "os"; +import { + getAutomakerDir, + getFeaturesDir, + getFeatureDir, + getFeatureImagesDir, + getBoardDir, + getImagesDir, + getWorktreesDir, + getAppSpecPath, + getBranchTrackingPath, + ensureAutomakerDir, +} from "@/lib/automaker-paths.js"; + +describe("automaker-paths.ts", () => { + const projectPath = "/test/project"; + + describe("getAutomakerDir", () => { + it("should return path to .automaker directory", () => { + expect(getAutomakerDir(projectPath)).toBe("/test/project/.automaker"); + }); + + it("should handle paths with trailing slashes", () => { + expect(getAutomakerDir("/test/project/")).toBe( + path.join("/test/project/", ".automaker") + ); + }); + }); + + describe("getFeaturesDir", () => { + it("should return path to features directory", () => { + expect(getFeaturesDir(projectPath)).toBe( + "/test/project/.automaker/features" + ); + }); + }); + + describe("getFeatureDir", () => { + it("should return path to specific feature directory", () => { + expect(getFeatureDir(projectPath, "feature-123")).toBe( + "/test/project/.automaker/features/feature-123" + ); + }); + + it("should handle feature IDs with special characters", () => { + expect(getFeatureDir(projectPath, "my-feature_v2")).toBe( + "/test/project/.automaker/features/my-feature_v2" + ); + }); + }); + + describe("getFeatureImagesDir", () => { + it("should return path to feature images directory", () => { + expect(getFeatureImagesDir(projectPath, "feature-123")).toBe( + "/test/project/.automaker/features/feature-123/images" + ); + }); + }); + + describe("getBoardDir", () => { + it("should return path to board directory", () => { + expect(getBoardDir(projectPath)).toBe("/test/project/.automaker/board"); + }); + }); + + describe("getImagesDir", () => { + it("should return path to images directory", () => { + expect(getImagesDir(projectPath)).toBe("/test/project/.automaker/images"); + }); + }); + + describe("getWorktreesDir", () => { + it("should return path to worktrees directory", () => { + expect(getWorktreesDir(projectPath)).toBe( + "/test/project/.automaker/worktrees" + ); + }); + }); + + describe("getAppSpecPath", () => { + it("should return path to app_spec.txt file", () => { + expect(getAppSpecPath(projectPath)).toBe( + "/test/project/.automaker/app_spec.txt" + ); + }); + }); + + describe("getBranchTrackingPath", () => { + it("should return path to active-branches.json file", () => { + expect(getBranchTrackingPath(projectPath)).toBe( + "/test/project/.automaker/active-branches.json" + ); + }); + }); + + describe("ensureAutomakerDir", () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `automaker-paths-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("should create automaker directory and return path", async () => { + const result = await ensureAutomakerDir(testDir); + + expect(result).toBe(path.join(testDir, ".automaker")); + const stats = await fs.stat(result); + expect(stats.isDirectory()).toBe(true); + }); + + it("should succeed if directory already exists", async () => { + const automakerDir = path.join(testDir, ".automaker"); + await fs.mkdir(automakerDir, { recursive: true }); + + const result = await ensureAutomakerDir(testDir); + + expect(result).toBe(automakerDir); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/fs-utils.test.ts b/apps/server/tests/unit/lib/fs-utils.test.ts new file mode 100644 index 00000000..9e7e9f22 --- /dev/null +++ b/apps/server/tests/unit/lib/fs-utils.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSafe, existsSafe } from "@/lib/fs-utils.js"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +describe("fs-utils.ts", () => { + let testDir: string; + + beforeEach(async () => { + // Create a temporary test directory + testDir = path.join(os.tmpdir(), `fs-utils-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("mkdirSafe", () => { + it("should create a new directory", async () => { + const newDir = path.join(testDir, "new-directory"); + await mkdirSafe(newDir); + + const stats = await fs.stat(newDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should succeed if directory already exists", async () => { + const existingDir = path.join(testDir, "existing"); + await fs.mkdir(existingDir); + + // Should not throw + await expect(mkdirSafe(existingDir)).resolves.toBeUndefined(); + }); + + it("should create nested directories", async () => { + const nestedDir = path.join(testDir, "a", "b", "c"); + await mkdirSafe(nestedDir); + + const stats = await fs.stat(nestedDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should throw if path exists as a file", async () => { + const filePath = path.join(testDir, "file.txt"); + await fs.writeFile(filePath, "content"); + + await expect(mkdirSafe(filePath)).rejects.toThrow( + "Path exists and is not a directory" + ); + }); + + it("should succeed if path is a symlink to a directory", async () => { + const realDir = path.join(testDir, "real-dir"); + const symlinkPath = path.join(testDir, "link-to-dir"); + await fs.mkdir(realDir); + await fs.symlink(realDir, symlinkPath); + + // Should not throw + await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined(); + }); + }); + + describe("existsSafe", () => { + it("should return true for existing file", async () => { + const filePath = path.join(testDir, "test-file.txt"); + await fs.writeFile(filePath, "content"); + + const exists = await existsSafe(filePath); + expect(exists).toBe(true); + }); + + it("should return true for existing directory", async () => { + const dirPath = path.join(testDir, "test-dir"); + await fs.mkdir(dirPath); + + const exists = await existsSafe(dirPath); + expect(exists).toBe(true); + }); + + it("should return false for non-existent path", async () => { + const nonExistent = path.join(testDir, "does-not-exist"); + + const exists = await existsSafe(nonExistent); + expect(exists).toBe(false); + }); + + it("should return true for symlink", async () => { + const realFile = path.join(testDir, "real-file.txt"); + const symlinkPath = path.join(testDir, "link-to-file"); + await fs.writeFile(realFile, "content"); + await fs.symlink(realFile, symlinkPath); + + const exists = await existsSafe(symlinkPath); + expect(exists).toBe(true); + }); + + it("should return true for broken symlink (symlink exists even if target doesn't)", async () => { + const symlinkPath = path.join(testDir, "broken-link"); + const nonExistent = path.join(testDir, "non-existent-target"); + await fs.symlink(nonExistent, symlinkPath); + + const exists = await existsSafe(symlinkPath); + expect(exists).toBe(true); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/logger.test.ts b/apps/server/tests/unit/lib/logger.test.ts new file mode 100644 index 00000000..7f76dbc6 --- /dev/null +++ b/apps/server/tests/unit/lib/logger.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + LogLevel, + createLogger, + getLogLevel, + setLogLevel, +} from "@/lib/logger.js"; + +describe("logger.ts", () => { + let consoleSpy: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let originalLogLevel: LogLevel; + + beforeEach(() => { + originalLogLevel = getLogLevel(); + consoleSpy = { + log: vi.spyOn(console, "log").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + setLogLevel(originalLogLevel); + consoleSpy.log.mockRestore(); + consoleSpy.warn.mockRestore(); + consoleSpy.error.mockRestore(); + }); + + describe("LogLevel enum", () => { + it("should have correct numeric values", () => { + expect(LogLevel.ERROR).toBe(0); + expect(LogLevel.WARN).toBe(1); + expect(LogLevel.INFO).toBe(2); + expect(LogLevel.DEBUG).toBe(3); + }); + }); + + describe("setLogLevel and getLogLevel", () => { + it("should set and get log level", () => { + setLogLevel(LogLevel.DEBUG); + expect(getLogLevel()).toBe(LogLevel.DEBUG); + + setLogLevel(LogLevel.ERROR); + expect(getLogLevel()).toBe(LogLevel.ERROR); + }); + }); + + describe("createLogger", () => { + it("should create a logger with context prefix", () => { + setLogLevel(LogLevel.INFO); + const logger = createLogger("TestContext"); + + logger.info("test message"); + + expect(consoleSpy.log).toHaveBeenCalledWith("[TestContext]", "test message"); + }); + + it("should log error at all log levels", () => { + const logger = createLogger("Test"); + + setLogLevel(LogLevel.ERROR); + logger.error("error message"); + expect(consoleSpy.error).toHaveBeenCalledWith("[Test]", "error message"); + }); + + it("should log warn when level is WARN or higher", () => { + const logger = createLogger("Test"); + + setLogLevel(LogLevel.ERROR); + logger.warn("warn message 1"); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.WARN); + logger.warn("warn message 2"); + expect(consoleSpy.warn).toHaveBeenCalledWith("[Test]", "warn message 2"); + }); + + it("should log info when level is INFO or higher", () => { + const logger = createLogger("Test"); + + setLogLevel(LogLevel.WARN); + logger.info("info message 1"); + expect(consoleSpy.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.INFO); + logger.info("info message 2"); + expect(consoleSpy.log).toHaveBeenCalledWith("[Test]", "info message 2"); + }); + + it("should log debug only when level is DEBUG", () => { + const logger = createLogger("Test"); + + setLogLevel(LogLevel.INFO); + logger.debug("debug message 1"); + expect(consoleSpy.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.DEBUG); + logger.debug("debug message 2"); + expect(consoleSpy.log).toHaveBeenCalledWith("[Test]", "[DEBUG]", "debug message 2"); + }); + + it("should pass multiple arguments to log functions", () => { + setLogLevel(LogLevel.DEBUG); + const logger = createLogger("Multi"); + + logger.info("message", { data: "value" }, 123); + expect(consoleSpy.log).toHaveBeenCalledWith( + "[Multi]", + "message", + { data: "value" }, + 123 + ); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts new file mode 100644 index 00000000..1cb2be26 --- /dev/null +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("sdk-options.ts", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + vi.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("TOOL_PRESETS", () => { + it("should export readOnly tools", async () => { + const { TOOL_PRESETS } = await import("@/lib/sdk-options.js"); + expect(TOOL_PRESETS.readOnly).toEqual(["Read", "Glob", "Grep"]); + }); + + it("should export specGeneration tools", async () => { + const { TOOL_PRESETS } = await import("@/lib/sdk-options.js"); + expect(TOOL_PRESETS.specGeneration).toEqual(["Read", "Glob", "Grep"]); + }); + + it("should export fullAccess tools", async () => { + const { TOOL_PRESETS } = await import("@/lib/sdk-options.js"); + expect(TOOL_PRESETS.fullAccess).toContain("Read"); + expect(TOOL_PRESETS.fullAccess).toContain("Write"); + expect(TOOL_PRESETS.fullAccess).toContain("Edit"); + expect(TOOL_PRESETS.fullAccess).toContain("Bash"); + }); + + it("should export chat tools matching fullAccess", async () => { + const { TOOL_PRESETS } = await import("@/lib/sdk-options.js"); + expect(TOOL_PRESETS.chat).toEqual(TOOL_PRESETS.fullAccess); + }); + }); + + describe("MAX_TURNS", () => { + it("should export turn presets", async () => { + const { MAX_TURNS } = await import("@/lib/sdk-options.js"); + expect(MAX_TURNS.quick).toBe(5); + expect(MAX_TURNS.standard).toBe(20); + expect(MAX_TURNS.extended).toBe(50); + expect(MAX_TURNS.maximum).toBe(1000); + }); + }); + + describe("getModelForUseCase", () => { + it("should return explicit model when provided", async () => { + const { getModelForUseCase } = await import("@/lib/sdk-options.js"); + const result = getModelForUseCase("spec", "claude-sonnet-4-20250514"); + expect(result).toBe("claude-sonnet-4-20250514"); + }); + + it("should use environment variable for spec model", async () => { + process.env.AUTOMAKER_MODEL_SPEC = "claude-sonnet-4-20250514"; + const { getModelForUseCase } = await import("@/lib/sdk-options.js"); + const result = getModelForUseCase("spec"); + expect(result).toBe("claude-sonnet-4-20250514"); + }); + + it("should use default model for spec when no override", async () => { + delete process.env.AUTOMAKER_MODEL_SPEC; + delete process.env.AUTOMAKER_MODEL_DEFAULT; + const { getModelForUseCase } = await import("@/lib/sdk-options.js"); + const result = getModelForUseCase("spec"); + expect(result).toContain("claude"); + }); + + it("should fall back to AUTOMAKER_MODEL_DEFAULT", async () => { + delete process.env.AUTOMAKER_MODEL_SPEC; + process.env.AUTOMAKER_MODEL_DEFAULT = "claude-sonnet-4-20250514"; + const { getModelForUseCase } = await import("@/lib/sdk-options.js"); + const result = getModelForUseCase("spec"); + expect(result).toBe("claude-sonnet-4-20250514"); + }); + }); + + describe("createSpecGenerationOptions", () => { + it("should create options with spec generation settings", async () => { + const { createSpecGenerationOptions, TOOL_PRESETS, MAX_TURNS } = + await import("@/lib/sdk-options.js"); + + const options = createSpecGenerationOptions({ cwd: "/test/path" }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]); + expect(options.permissionMode).toBe("acceptEdits"); + }); + + it("should include system prompt when provided", async () => { + const { createSpecGenerationOptions } = await import( + "@/lib/sdk-options.js" + ); + + const options = createSpecGenerationOptions({ + cwd: "/test/path", + systemPrompt: "Custom prompt", + }); + + expect(options.systemPrompt).toBe("Custom prompt"); + }); + + it("should include abort controller when provided", async () => { + const { createSpecGenerationOptions } = await import( + "@/lib/sdk-options.js" + ); + + const abortController = new AbortController(); + const options = createSpecGenerationOptions({ + cwd: "/test/path", + abortController, + }); + + expect(options.abortController).toBe(abortController); + }); + }); + + describe("createFeatureGenerationOptions", () => { + it("should create options with feature generation settings", async () => { + const { createFeatureGenerationOptions, TOOL_PRESETS, MAX_TURNS } = + await import("@/lib/sdk-options.js"); + + const options = createFeatureGenerationOptions({ cwd: "/test/path" }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(MAX_TURNS.quick); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + }); + + describe("createSuggestionsOptions", () => { + it("should create options with suggestions settings", async () => { + const { createSuggestionsOptions, TOOL_PRESETS, MAX_TURNS } = await import( + "@/lib/sdk-options.js" + ); + + const options = createSuggestionsOptions({ cwd: "/test/path" }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(MAX_TURNS.quick); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + }); + + describe("createChatOptions", () => { + it("should create options with chat settings", async () => { + const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import( + "@/lib/sdk-options.js" + ); + + const options = createChatOptions({ cwd: "/test/path" }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(MAX_TURNS.standard); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + it("should prefer explicit model over session model", async () => { + const { createChatOptions, getModelForUseCase } = await import( + "@/lib/sdk-options.js" + ); + + const options = createChatOptions({ + cwd: "/test/path", + model: "claude-opus-4-20250514", + sessionModel: "claude-haiku-3-5-20241022", + }); + + expect(options.model).toBe("claude-opus-4-20250514"); + }); + + it("should use session model when explicit model not provided", async () => { + const { createChatOptions } = await import("@/lib/sdk-options.js"); + + const options = createChatOptions({ + cwd: "/test/path", + sessionModel: "claude-sonnet-4-20250514", + }); + + expect(options.model).toBe("claude-sonnet-4-20250514"); + }); + }); + + describe("createAutoModeOptions", () => { + it("should create options with auto mode settings", async () => { + const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = await import( + "@/lib/sdk-options.js" + ); + + const options = createAutoModeOptions({ cwd: "/test/path" }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + }); + + describe("createCustomOptions", () => { + it("should create options with custom settings", async () => { + const { createCustomOptions } = await import("@/lib/sdk-options.js"); + + const options = createCustomOptions({ + cwd: "/test/path", + maxTurns: 10, + allowedTools: ["Read", "Write"], + sandbox: { enabled: true }, + }); + + expect(options.cwd).toBe("/test/path"); + expect(options.maxTurns).toBe(10); + expect(options.allowedTools).toEqual(["Read", "Write"]); + expect(options.sandbox).toEqual({ enabled: true }); + }); + + it("should use defaults when optional params not provided", async () => { + const { createCustomOptions, TOOL_PRESETS, MAX_TURNS } = await import( + "@/lib/sdk-options.js" + ); + + const options = createCustomOptions({ cwd: "/test/path" }); + + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + }); +}); diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts new file mode 100644 index 00000000..efa36842 --- /dev/null +++ b/apps/server/tests/unit/services/dev-server-service.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "events"; +import path from "path"; +import os from "os"; +import fs from "fs/promises"; + +// Mock child_process +vi.mock("child_process", () => ({ + spawn: vi.fn(), + execSync: vi.fn(), +})); + +// Mock fs existsSync +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + }; +}); + +// Mock net +vi.mock("net", () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import { spawn, execSync } from "child_process"; +import { existsSync } from "fs"; +import net from "net"; + +describe("dev-server-service.ts", () => { + let testDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + + // Default mock for existsSync - return true + vi.mocked(existsSync).mockReturnValue(true); + + // Default mock for net.createServer - port available + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit("listening")); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + // Default mock for execSync - no process on port + vi.mocked(execSync).mockImplementation(() => { + throw new Error("No process found"); + }); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("getDevServerService", () => { + it("should return a singleton instance", async () => { + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + + const instance1 = getDevServerService(); + const instance2 = getDevServerService(); + + expect(instance1).toBe(instance2); + }); + }); + + describe("startDevServer", () => { + it("should return error if worktree path does not exist", async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + const result = await service.startDevServer( + "/project", + "/nonexistent/worktree" + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + }); + + it("should return error if no package.json found", async () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + if (p.includes("package.json")) return false; + return true; + }); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + expect(result.success).toBe(false); + expect(result.error).toContain("No package.json found"); + }); + + it("should detect npm as package manager with package-lock.json", async () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + if (p.includes("bun.lockb")) return false; + if (p.includes("pnpm-lock.yaml")) return false; + if (p.includes("yarn.lock")) return false; + if (p.includes("package-lock.json")) return true; + if (p.includes("package.json")) return true; + return true; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith( + "npm", + ["run", "dev"], + expect.any(Object) + ); + }); + + it("should detect yarn as package manager with yarn.lock", async () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + if (p.includes("bun.lockb")) return false; + if (p.includes("pnpm-lock.yaml")) return false; + if (p.includes("yarn.lock")) return true; + if (p.includes("package.json")) return true; + return true; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith("yarn", ["dev"], expect.any(Object)); + }); + + it("should detect pnpm as package manager with pnpm-lock.yaml", async () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + if (p.includes("bun.lockb")) return false; + if (p.includes("pnpm-lock.yaml")) return true; + if (p.includes("package.json")) return true; + return true; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith( + "pnpm", + ["run", "dev"], + expect.any(Object) + ); + }); + + it("should detect bun as package manager with bun.lockb", async () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + if (p.includes("bun.lockb")) return true; + if (p.includes("package.json")) return true; + return true; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith( + "bun", + ["run", "dev"], + expect.any(Object) + ); + }); + + it("should return existing server info if already running", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + // Start first server + const result1 = await service.startDevServer(testDir, testDir); + expect(result1.success).toBe(true); + + // Try to start again - should return existing + const result2 = await service.startDevServer(testDir, testDir); + expect(result2.success).toBe(true); + expect(result2.result?.message).toContain("already running"); + }); + + it("should start dev server successfully", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result?.port).toBeGreaterThanOrEqual(3001); + expect(result.result?.url).toContain("http://localhost:"); + }); + }); + + describe("stopDevServer", () => { + it("should return success if server not found", async () => { + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + const result = await service.stopDevServer("/nonexistent/path"); + + expect(result.success).toBe(true); + expect(result.result?.message).toContain("already stopped"); + }); + + it("should stop a running server", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + // Start server + await service.startDevServer(testDir, testDir); + + // Stop server + const result = await service.stopDevServer(testDir); + + expect(result.success).toBe(true); + expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM"); + }); + }); + + describe("listDevServers", () => { + it("should return empty list when no servers running", async () => { + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + const result = service.listDevServers(); + + expect(result.success).toBe(true); + expect(result.result.servers).toEqual([]); + }); + + it("should list running servers", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const result = service.listDevServers(); + + expect(result.success).toBe(true); + expect(result.result.servers.length).toBeGreaterThanOrEqual(1); + expect(result.result.servers[0].worktreePath).toBe(testDir); + }); + }); + + describe("isRunning", () => { + it("should return false for non-running server", async () => { + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + expect(service.isRunning("/some/path")).toBe(false); + }); + + it("should return true for running server", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(service.isRunning(testDir)).toBe(true); + }); + }); + + describe("getServerInfo", () => { + it("should return undefined for non-running server", async () => { + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + expect(service.getServerInfo("/some/path")).toBeUndefined(); + }); + + it("should return info for running server", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const info = service.getServerInfo(testDir); + expect(info).toBeDefined(); + expect(info?.worktreePath).toBe(testDir); + expect(info?.port).toBeGreaterThanOrEqual(3001); + }); + }); + + describe("getAllocatedPorts", () => { + it("should return allocated ports", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const ports = service.getAllocatedPorts(); + expect(ports.length).toBeGreaterThanOrEqual(1); + expect(ports[0]).toBeGreaterThanOrEqual(3001); + }); + }); + + describe("stopAll", () => { + it("should stop all running servers", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import( + "@/services/dev-server-service.js" + ); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + await service.stopAll(); + + expect(service.listDevServers().result.servers).toHaveLength(0); + }); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + + // Don't exit immediately - let the test control the lifecycle + return mockProcess; +}