diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18f5c60 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings to LF across all platforms +* text=auto eol=lf + +# Binary files should not be normalized +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.eot binary +*.ttf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8600c..4dc891f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - windows-latest - macos-latest node-version: + - "20" - "22" - "24" diff --git a/package-lock.json b/package-lock.json index 17fa96f..958eed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", @@ -2249,6 +2250,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2262,6 +2281,45 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", @@ -2911,6 +2969,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -3060,6 +3128,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 5405b26..f1fd660 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "check": "npm run typecheck && npm run lint && npm run format:check", "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "tsx --test src/tests/*.test.ts", + "test": "node src/tests/run-tests.mjs", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" @@ -58,6 +58,7 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index f89e11d..9636732 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -128,10 +128,10 @@ export class McpClient { const isWindows = os.platform() === "win32"; if (isWindows) { - // On Windows, .cmd files require shell: true to be spawned. - // Build a single command string so cmd.exe handles quoting correctly. - const cmd = [this.command + ".cmd", ...args].join(" "); - this.process = spawn(cmd, [], { + // On Windows, shell: true lets cmd.exe resolve the command via + // PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd, + // which would break absolute paths like process.execPath. + this.process = spawn(this.command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, shell: true, diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index 022b2f8..dbe9ff9 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -36,40 +36,44 @@ test("readClipboardImage returns null when no clipboard helpers are installed", assert.equal(result, null); }); -test("readClipboardImage uses osascript fallback on macOS when pngpaste is missing", async () => { - const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); - try { - fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(binDir, "osascript"), - [ - "#!/bin/sh", - 'for arg in "$@"; do', - ' case "$arg" in', - " *'open for access POSIX file " + '"' + "'*)", - ' path_part=${arg#*POSIX file \\"}', - ' out_path=${path_part%%\\"*}', - ' printf fakepng > "$out_path"', - " exit 0", - " ;;", - " esac", - "done", - "exit 1", - "", - ].join("\n"), - { mode: 0o755 } - ); +test( + "readClipboardImage uses osascript fallback on macOS when pngpaste is missing", + { skip: process.platform === "win32" }, + async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); + try { + fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(binDir, "osascript"), + [ + "#!/bin/sh", + 'for arg in "$@"; do', + ' case "$arg" in', + " *'open for access POSIX file " + '"' + "'*)", + ' path_part=${arg#*POSIX file \\"}', + ' out_path=${path_part%%\\"*}', + ' printf fakepng > "$out_path"', + " exit 0", + " ;;", + " esac", + "done", + "exit 1", + "", + ].join("\n"), + { mode: 0o755 } + ); - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; + const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; - process.env.PATH = binDir; - const result = withPlatform("darwin", () => readClipboardImage()); - assert.equal(result?.mimeType, "image/png"); - assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); - } finally { - process.env.PATH = ORIGINAL_PATH; - Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); - fs.rmSync(binDir, { recursive: true, force: true }); + process.env.PATH = binDir; + const result = withPlatform("darwin", () => readClipboardImage()); + assert.equal(result?.mimeType, "image/png"); + assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); + } finally { + process.env.PATH = ORIGINAL_PATH; + Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); + fs.rmSync(binDir, { recursive: true, force: true }); + } } -}); +); diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs new file mode 100644 index 0000000..4d09f5b --- /dev/null +++ b/src/tests/run-tests.mjs @@ -0,0 +1,13 @@ +// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. +// Uses the glob package for reliable cross-platform pattern expansion (Node 20+). +/* eslint-disable */ + +import { globSync } from "glob"; +import { spawnSync } from "child_process"; + +const cwd = new URL("../..", import.meta.url); +const testFiles = globSync("src/tests/*.test.ts", { cwd }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { stdio: "inherit", cwd }); + +process.exit(result.status ?? 1); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index ff684a3..3dad6df 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -7,8 +7,17 @@ import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; +/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + afterEach(() => { globalThis.fetch = originalFetch; if (originalHome === undefined) { @@ -16,6 +25,11 @@ afterEach(() => { } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -249,7 +263,7 @@ test("SessionManager replays normal assistant messages with reasoning content in test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { const workspace = createTempDir("deepcode-legacy-active-tokens-workspace-"); const home = createTempDir("deepcode-legacy-active-tokens-home-"); - process.env.HOME = home; + setHomeDir(home); const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const projectDir = path.join(home, ".deepcode", "projects", projectCode); @@ -281,7 +295,7 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( test("SessionManager keeps usagePerModel null until response usage is available", async () => { const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); const home = createTempDir("deepcode-null-usage-per-model-home-"); - process.env.HOME = home; + setHomeDir(home); const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); @@ -294,7 +308,7 @@ test("SessionManager keeps usagePerModel null until response usage is available" test("SessionManager marks skills loaded from existing session messages", async () => { const workspace = createTempDir("deepcode-loaded-skills-workspace-"); const home = createTempDir("deepcode-loaded-skills-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); fs.mkdirSync(skillDir, { recursive: true }); @@ -341,7 +355,7 @@ test("SessionManager marks skills loaded from existing session messages", async test("SessionManager lists project skills from .agents with legacy .deepcode compatibility", async () => { const workspace = createTempDir("deepcode-project-skills-workspace-"); const home = createTempDir("deepcode-project-skills-home-"); - process.env.HOME = home; + setHomeDir(home); const userSkillDir = path.join(home, ".agents", "skills", "shared"); fs.mkdirSync(userSkillDir, { recursive: true }); @@ -514,13 +528,16 @@ test("SessionManager reports MCP startup stderr on failure", async () => { assert.match(status?.error ?? "", /mcp startup boom/); }); -test("SessionManager adds -y when launching MCP servers through npx", async () => { - const workspace = createTempDir("deepcode-mcp-npx-workspace-"); - const argsPath = path.join(workspace, "args.json"); - const fakeNpxPath = path.join(workspace, "npx"); - fs.writeFileSync( - fakeNpxPath, - `#!/usr/bin/env node +test( + "SessionManager adds -y when launching MCP servers through npx", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-mcp-npx-workspace-"); + const argsPath = path.join(workspace, "args.json"); + const fakeNpxPath = path.join(workspace, "npx"); + fs.writeFileSync( + fakeNpxPath, + `#!/usr/bin/env node const fs = require("fs"); const readline = require("readline"); fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); @@ -544,23 +561,24 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); }); `, - "utf8" - ); - fs.chmodSync(fakeNpxPath, 0o755); + "utf8" + ); + fs.chmodSync(fakeNpxPath, 0o755); - const manager = createSessionManager(workspace, "machine-id-mcp-npx"); - await manager.initMcpServers({ - npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, - }); + const manager = createSessionManager(workspace, "machine-id-mcp-npx"); + await manager.initMcpServers({ + npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, + }); - assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); - manager.dispose(); -}); + assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); + manager.dispose(); + } +); test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); @@ -592,7 +610,7 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); @@ -619,7 +637,7 @@ test("replySession stores /init and sends the active root project AGENTS path to test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { const workspace = createTempDir("deepcode-init-generate-workspace-"); const home = createTempDir("deepcode-init-generate-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); @@ -645,7 +663,7 @@ test("createSession stores /init and sends generate prompt when no project AGENT test("createSession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { @@ -677,7 +695,7 @@ test("createSession reports a new prompt with the machineId token", async () => test("replySession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-reply-workspace-"); const home = createTempDir("deepcode-reply-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { @@ -708,7 +726,7 @@ test("replySession reports a new prompt with the machineId token", async () => { test("replySession preserves raw session messages when a previous tool call is pending", async () => { const workspace = createTempDir("deepcode-pending-tool-workspace-"); const home = createTempDir("deepcode-pending-tool-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ @@ -1131,7 +1149,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista test("SessionManager accumulates response usage while active tokens track the latest response", async () => { const workspace = createTempDir("deepcode-usage-workspace-"); const home = createTempDir("deepcode-usage-home-"); - process.env.HOME = home; + setHomeDir(home); const responses = [ createChatResponse("first", { @@ -1182,7 +1200,7 @@ test("SessionManager accumulates response usage while active tokens track the la test("SessionManager stores usage per model across model changes", async () => { const workspace = createTempDir("deepcode-usage-per-model-workspace-"); const home = createTempDir("deepcode-usage-per-model-home-"); - process.env.HOME = home; + setHomeDir(home); let currentModel = "deepseek-v4-pro"; const responses = [ @@ -1243,7 +1261,7 @@ test("SessionManager stores usage per model across model changes", async () => { test("SessionManager resets active tokens to latest post-compaction response usage", async () => { const workspace = createTempDir("deepcode-compact-usage-workspace-"); const home = createTempDir("deepcode-compact-usage-home-"); - process.env.HOME = home; + setHomeDir(home); const responses = [ createChatResponse("large", { @@ -1285,7 +1303,7 @@ test("SessionManager resets active tokens to latest post-compaction response usa test("SessionManager streams chat completions and counts reasoning progress", async () => { const workspace = createTempDir("deepcode-stream-workspace-"); const home = createTempDir("deepcode-stream-home-"); - process.env.HOME = home; + setHomeDir(home); const progressEvents: Array<{ phase: string; @@ -1352,7 +1370,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as test("SessionManager cancels skill matching before a session is created", async () => { const workspace = createTempDir("deepcode-skill-abort-workspace-"); const home = createTempDir("deepcode-skill-abort-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "demo"); fs.mkdirSync(skillDir, { recursive: true }); @@ -1384,7 +1402,7 @@ test("SessionManager cancels skill matching before a session is created", async test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { const workspace = createTempDir("deepcode-api-abort-workspace-"); const home = createTempDir("deepcode-api-abort-home-"); - process.env.HOME = home; + setHomeDir(home); let manager: SessionManager; const client = { diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 69b939f..6990288 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -364,39 +364,43 @@ test("buildNotifyEnv injects DURATION", () => { assert.equal(env.DURATION, "2"); }); -test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", () => { - const calls: Array<{ - command: string; - args: string[]; - options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; - }> = []; - - const spawnProcess: NotifySpawn = (command, args, options) => { - calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); - - return { - once(event, listener) { - if (event === "error" && calls.length === 1) { - listener({ code: "EACCES" } as NodeJS.ErrnoException); - } - return this; - }, - unref() { - return undefined; - }, +test( + "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", + { skip: process.platform === "win32" }, + () => { + const calls: Array<{ + command: string; + args: string[]; + options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; + }> = []; + + const spawnProcess: NotifySpawn = (command, args, options) => { + calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); + + return { + once(event, listener) { + if (event === "error" && calls.length === 1) { + listener({ code: "EACCES" } as NodeJS.ErrnoException); + } + return this; + }, + unref() { + return undefined; + }, + }; }; - }; - - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); - - assert.equal(calls.length, 2); - assert.equal(calls[0]?.command, "/tmp/notify.sh"); - assert.deepEqual(calls[0]?.args, []); - assert.equal(calls[0]?.options.cwd, "/tmp/project"); - assert.equal(calls[0]?.options.env?.DURATION, "2"); - assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); - assert.equal(calls[1]?.command, "/bin/sh"); - assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); - assert.equal(calls[1]?.options.cwd, "/tmp/project"); - assert.equal(calls[1]?.options.env?.DURATION, "2"); -}); + + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); + + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, "/tmp/notify.sh"); + assert.deepEqual(calls[0]?.args, []); + assert.equal(calls[0]?.options.cwd, "/tmp/project"); + assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[1]?.command, "/bin/sh"); + assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); + assert.equal(calls[1]?.options.cwd, "/tmp/project"); + assert.equal(calls[1]?.options.env?.DURATION, "2"); + } +); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index 576a1cb..417c6c4 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -20,40 +20,44 @@ afterEach(() => { } }); -test("WebSearch executes the configured script with the query as one argument", async () => { - const workspace = createTempWorkspace(); - const scriptPath = path.join(workspace, "web-search.sh"); - fs.writeFileSync( - scriptPath, - [ - "#!/bin/sh", - "printf 'query=%s\\n' \"$1\"", - "printf 'cwd=%s\\n' \"$PWD\"", - "printf 'webhook=%s\\n' \"$WEBHOOK\"", - ].join("\n"), - "utf8" - ); - fs.chmodSync(scriptPath, 0o755); +test( + "WebSearch executes the configured script with the query as one argument", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempWorkspace(); + const scriptPath = path.join(workspace, "web-search.sh"); + fs.writeFileSync( + scriptPath, + [ + "#!/bin/sh", + "printf 'query=%s\\n' \"$1\"", + "printf 'cwd=%s\\n' \"$PWD\"", + "printf 'webhook=%s\\n' \"$WEBHOOK\"", + ].join("\n"), + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); - const starts: Array<{ id: string | number; command: string }> = []; - const exits: Array = []; - const result = await handleWebSearchTool( - { query: "latest node release" }, - createContext(workspace, { - webSearchTool: scriptPath, - env: { WEBHOOK: "configured" }, - onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id), - }) - ); - const realWorkspace = fs.realpathSync(workspace); + const starts: Array<{ id: string | number; command: string }> = []; + const exits: Array = []; + const result = await handleWebSearchTool( + { query: "latest node release" }, + createContext(workspace, { + webSearchTool: scriptPath, + env: { WEBHOOK: "configured" }, + onProcessStart: (id, command) => starts.push({ id, command }), + onProcessExit: (id) => exits.push(id), + }) + ); + const realWorkspace = fs.realpathSync(workspace); - assert.equal(result.ok, true); - assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); - assert.equal(starts.length, 1); - assert.match(starts[0].command, /^WebSearch: latest node release$/); - assert.deepEqual(exits, [starts[0].id]); -}); + assert.equal(result.ok, true); + assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); + assert.equal(starts.length, 1); + assert.match(starts[0].command, /^WebSearch: latest node release$/); + assert.deepEqual(exits, [starts[0].id]); + } +); test("WebSearch uses the default API when no script is configured", async () => { const workspace = createTempWorkspace(); diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index 1e5bc19..df7e109 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -1,17 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import * as os from "os"; +import * as path from "path"; import { buildWelcomeTips, formatHomeRelativePath } from "../ui"; test("formatHomeRelativePath returns tilde for the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example", "/Users/example"), "~"); + const home = path.resolve("/Users/example"); + assert.equal(formatHomeRelativePath(home, home), "~"); }); test("formatHomeRelativePath shortens paths inside the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example/dev/project", "/Users/example"), "~/dev/project"); + const home = path.resolve("/Users/example"); + const result = formatHomeRelativePath(path.resolve("/Users/example/dev/project"), home); + assert.equal(result, `~${path.sep}dev${path.sep}project`); }); test("formatHomeRelativePath keeps paths outside the home directory absolute", () => { - assert.equal(formatHomeRelativePath("/tmp/project", "/Users/example"), "/tmp/project"); + const home = path.resolve("/Users/example"); + const other = path.resolve("/tmp/project"); + // The result should be the absolute path since it's outside home + const result = formatHomeRelativePath(other, home); + assert.equal(result, other); }); test("buildWelcomeTips includes built-in slash commands and loaded skills", () => {