Add a goosed over HTTP integration test, and test the developer tool PATH (#7178)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jack Amadeo
2026-02-13 14:41:18 -05:00
committed by GitHub
parent 0206035dba
commit 5ba5b9899d
17 changed files with 864 additions and 354 deletions
+44 -2
View File
@@ -67,15 +67,22 @@ jobs:
- name: Build Binary for Smoke Tests
run: |
cargo build --bin goose
cargo build --bin goose --bin goosed
- name: Upload Binary for Smoke Tests
- name: Upload goose binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: goose-binary
path: target/debug/goose
retention-days: 1
- name: Upload goosed binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: goosed-binary
path: target/debug/goosed
retention-days: 1
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
@@ -239,3 +246,38 @@ jobs:
mkdir -p $HOME/.local/share/goose/sessions
mkdir -p $HOME/.config/goose
bash scripts/test_compaction.sh
goosed-integration-tests:
name: goose server HTTP integration tests
runs-on: ubuntu-latest
needs: build-binary
steps:
- name: Checkout Code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.inputs.branch || github.ref }}
- name: Download Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: goosed-binary
path: target/debug
- name: Make Binary Executable
run: chmod +x target/debug/goosed
- name: Install Node.js Dependencies
run: source ../../bin/activate-hermit && npm ci
working-directory: ui/desktop
- name: Run Integration Tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOSED_BINARY: ../../target/debug/goosed
GOOSE_PROVIDER: anthropic
GOOSE_MODEL: claude-sonnet-4-5-20250929
SHELL: /bin/bash
run: |
echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile
source ../../bin/activate-hermit && npm run test:integration:debug
working-directory: ui/desktop
+3
View File
@@ -35,6 +35,9 @@
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:integration:debug": "DEBUG=1 vitest run --config vitest.integration.config.ts",
"prepare": "husky",
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
-5
View File
@@ -28,11 +28,6 @@ Object.defineProperty(window, 'history', {
writable: true,
});
// Mock dependencies
vi.mock('./utils/providerUtils', () => ({
initializeSystem: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./utils/costDatabase', () => ({
initializeCostDatabase: vi.fn().mockResolvedValue(undefined),
}));
+1 -1
View File
@@ -34,7 +34,7 @@ import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage, getTextAndImageContent } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
import { substituteParameters } from '../utils/providerUtils';
import { substituteParameters } from '../utils/parameterSubstitution';
import { useModelAndProvider } from './ModelAndProviderContext';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { OllamaSetup } from './OllamaSetup';
import * as ollamaDetection from '../utils/ollamaDetection';
import * as providerUtils from '../utils/providerUtils';
import { toastService } from '../toasts';
// Mock dependencies
vi.mock('../utils/ollamaDetection');
vi.mock('../utils/providerUtils');
vi.mock('../toasts');
// Mock useConfig hook
@@ -162,8 +160,6 @@ describe('OllamaSetup', () => {
});
it('should handle successful connection', async () => {
vi.mocked(providerUtils.initializeSystem).mockResolvedValue(undefined);
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
await waitFor(() => {
@@ -174,20 +170,12 @@ describe('OllamaSetup', () => {
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_PROVIDER', 'ollama', false);
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_MODEL', 'gpt-oss:20b', false);
expect(mockUpsert).toHaveBeenCalledWith('OLLAMA_HOST', 'localhost', false);
expect(providerUtils.initializeSystem).toHaveBeenCalledWith(
'ollama',
'gpt-oss:20b',
expect.any(Object)
);
expect(toastService.success).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should handle connection failure', async () => {
const testError = new Error('Initialization failed');
vi.mocked(providerUtils.initializeSystem).mockRejectedValue(testError);
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
await waitFor(() => {
@@ -1,7 +1,7 @@
import { Card } from '../ui/card';
import GooseLogo from '../GooseLogo';
import MarkdownContent from '../MarkdownContent';
import { substituteParameters } from '../../utils/providerUtils';
import { substituteParameters } from '../../utils/parameterSubstitution';
interface RecipeActivitiesProps {
append: (text: string) => void;
+304 -233
View File
@@ -1,16 +1,21 @@
import Electron from 'electron';
import fs from 'node:fs';
import { spawn, ChildProcess } from 'child_process';
import { createServer } from 'net';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import log from './utils/logger';
import { App } from 'electron';
import { createServer } from 'net';
import { Buffer } from 'node:buffer';
import { status } from './api';
import { Client } from './api/client';
import { ExternalGoosedConfig } from './utils/settings';
import { Client, createClient, createConfig } from './api/client';
export interface Logger {
info: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
export const defaultLogger: Logger = {
info: (...args) => console.log('[goosed]', ...args),
error: (...args) => console.error('[goosed]', ...args),
};
export const findAvailablePort = (): Promise<number> => {
return new Promise((resolve, _reject) => {
@@ -19,244 +24,310 @@ export const findAvailablePort = (): Promise<number> => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as { port: number };
server.close(() => {
log.info(`Found available port: ${port}`);
resolve(port);
});
});
});
};
// Check if goosed server is ready by polling the status endpoint
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
const interval = 100; // ms
const maxAttempts = 100; // 10s
export interface FindBinaryOptions {
isPackaged?: boolean;
resourcesPath?: string;
}
const fatal = (line: string) => {
const trimmed = line.trim().toLowerCase();
return trimmed.startsWith("thread 'main' panicked at") || trimmed.startsWith('error:');
};
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (errorLog.some(fatal)) {
log.error('Detected fatal error in server logs');
return false;
export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => {
const pathFromEnv = process.env.GOOSED_BINARY;
if (pathFromEnv) {
if (fs.existsSync(pathFromEnv) && fs.statSync(pathFromEnv).isFile()) {
return path.resolve(pathFromEnv);
} else {
throw new Error(`Invalid GOOSED_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
}
}
const { isPackaged = false, resourcesPath } = options;
const binaryName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
const possiblePaths: string[] = [];
// Packaged app paths
if (isPackaged && resourcesPath) {
possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
possiblePaths.push(path.join(resourcesPath, binaryName));
}
// Development paths
possiblePaths.push(
path.join(process.cwd(), 'src', 'bin', binaryName),
path.join(process.cwd(), '..', '..', 'target', 'release', binaryName),
path.join(process.cwd(), '..', '..', 'target', 'debug', binaryName)
);
for (const p of possiblePaths) {
try {
await status({ client, throwOnError: true });
return true;
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
return p;
}
} catch {
if (attempt === maxAttempts) {
log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`);
}
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
return false;
};
export interface GoosedResult {
baseUrl: string;
workingDir: string;
process: ChildProcess;
errorLog: string[];
}
const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => {
log.info(`Using external goosed backend at ${url}`);
const mockProcess = {
pid: undefined,
kill: () => {
log.info(`Not killing external process that is managed externally`);
},
} as ChildProcess;
return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] };
};
interface GooseProcessEnv {
[key: string]: string | undefined;
HOME: string;
USERPROFILE: string;
APPDATA: string;
LOCALAPPDATA: string;
PATH: string;
GOOSE_PORT: string;
GOOSE_SERVER__SECRET_KEY?: string;
}
export interface StartGoosedOptions {
app: App;
serverSecret: string;
dir: string;
env?: Partial<GooseProcessEnv>;
externalGoosed?: ExternalGoosedConfig;
}
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
const { app, serverSecret, dir: inputDir, env = {}, externalGoosed } = options;
const isWindows = process.platform === 'win32';
const homeDir = os.homedir();
const dir = path.resolve(path.normalize(inputDir));
if (externalGoosed?.enabled && externalGoosed.url) {
return connectToExternalBackend(dir, externalGoosed.url);
}
if (process.env.GOOSE_EXTERNAL_BACKEND) {
const port = process.env.GOOSE_PORT || '3000';
return connectToExternalBackend(dir, `http://127.0.0.1:${port}`);
}
let goosedPath = getGoosedBinaryPath(app);
const resolvedGoosedPath = path.resolve(goosedPath);
const port = await findAvailablePort();
const stderrLines: string[] = [];
log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`);
const additionalEnv: GooseProcessEnv = {
HOME: homeDir,
USERPROFILE: homeDir,
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`,
GOOSE_PORT: String(port),
GOOSE_SERVER__SECRET_KEY: serverSecret,
...env,
} as GooseProcessEnv;
const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) {
goosedPath = resolvedGoosedPath + '.exe';
} else {
goosedPath = resolvedGoosedPath;
}
log.info(`Binary path resolved to: ${goosedPath}`);
const spawnOptions = {
cwd: dir,
env: processEnv,
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
windowsHide: true,
detached: isWindows,
shell: false,
};
const safeSpawnOptions = {
...spawnOptions,
env: Object.keys(spawnOptions.env || {}).reduce(
(acc, key) => {
if (key.includes('SECRET') || key.includes('PASSWORD') || key.includes('TOKEN')) {
acc[key] = '[REDACTED]';
} else {
acc[key] = spawnOptions.env![key] || '';
}
return acc;
},
{} as Record<string, string>
),
};
log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
const safeArgs = ['agent'];
const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions);
if (isWindows && goosedProcess.unref) {
goosedProcess.unref();
}
goosedProcess.stdout?.on('data', (data: Buffer) => {
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
});
goosedProcess.stderr?.on('data', (data: Buffer) => {
const lines = data
.toString()
.split('\n')
.filter((l) => l.trim());
lines.forEach((line) => {
log.error(`goosed stderr for port ${port} and dir ${dir}: ${line}`);
stderrLines.push(line);
});
});
goosedProcess.on('close', (code: number | null) => {
log.info(`goosed process exited with code ${code} for port ${port} and dir ${dir}`);
});
goosedProcess.on('error', (err: Error) => {
log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
throw err;
});
const try_kill_goose = () => {
try {
if (isWindows) {
const pid = goosedProcess.pid?.toString() || '0';
spawn('taskkill', ['/pid', pid, '/T', '/F'], { shell: false });
} else {
goosedProcess.kill?.();
}
} catch (error) {
log.error('Error while terminating goosed process:', error);
}
};
app.on('will-quit', () => {
log.info('App quitting, terminating goosed server');
try_kill_goose();
});
log.info(`Goosed server successfully started on port ${port}`);
return {
baseUrl: `http://127.0.0.1:${port}`,
workingDir: dir,
process: goosedProcess,
errorLog: stderrLines,
};
};
const getGoosedBinaryPath = (app: Electron.App): string => {
let executableName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
let possiblePaths: string[];
if (!app.isPackaged) {
possiblePaths = [
path.join(process.cwd(), 'src', 'bin', executableName),
path.join(process.cwd(), 'bin', executableName),
path.join(process.cwd(), '..', '..', 'target', 'debug', executableName),
path.join(process.cwd(), '..', '..', 'target', 'release', executableName),
];
} else {
possiblePaths = [path.join(process.resourcesPath, 'bin', executableName)];
}
for (const binPath of possiblePaths) {
try {
const resolvedPath = path.resolve(binPath);
if (fs.existsSync(resolvedPath)) {
const stats = fs.statSync(resolvedPath);
if (stats.isFile()) {
return resolvedPath;
} else {
log.error(`Path exists but is not a regular file: ${resolvedPath}`);
}
}
} catch (error) {
log.error(`Error checking path ${binPath}:`, error);
// continue
}
}
throw new Error(
`Could not find ${executableName} binary in any of the expected locations: ${possiblePaths.join(
', '
)}`
`Goosed binary not found in any of the possible paths: ${possiblePaths.join(', ')}`
);
};
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
const timeout = 10000;
const interval = 100;
const maxAttempts = Math.ceil(timeout / interval);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (errorLog.some(isFatalError)) {
return false;
}
try {
await status({ client, throwOnError: true });
return true;
} catch {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
return false;
};
export const isFatalError = (line: string): boolean => {
const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
return fatalPatterns.some((pattern) => pattern.test(line));
};
export const buildGoosedEnv = (
port: number,
secretKey: string,
binaryPath?: string
): Record<string, string> => {
// Environment variable naming follows the config crate convention:
// - GOOSE_ prefix with _ separator for top-level fields (GOOSE_PORT, GOOSE_HOST)
// - __ separator for nested fields (GOOSE_SERVER__SECRET_KEY)
const homeDir = process.env.HOME || os.homedir();
const env: Record<string, string> = {
GOOSE_PORT: port.toString(),
GOOSE_SERVER__SECRET_KEY: secretKey,
HOME: homeDir,
};
// Windows-specific environment variables
if (process.platform === 'win32') {
env.USERPROFILE = homeDir;
env.APPDATA = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
env.LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
}
// Add binary directory to PATH for any dependencies
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
const currentPath = process.env[pathKey] || '';
if (binaryPath) {
env[pathKey] = `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`;
} else if (currentPath) {
env[pathKey] = currentPath;
}
return env;
};
// Configuration for external goosed server
export interface ExternalGoosedConfig {
enabled: boolean;
url?: string;
secret?: string;
}
export interface StartGoosedOptions {
dir?: string;
serverSecret: string;
env?: Record<string, string | undefined>;
externalGoosed?: ExternalGoosedConfig;
isPackaged?: boolean;
resourcesPath?: string;
logger?: Logger;
}
export interface GoosedResult {
baseUrl: string;
workingDir: string;
process: ChildProcess | null;
errorLog: string[];
cleanup: () => Promise<void>;
client: Client;
}
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
return createClient(
createConfig({
baseUrl: url,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secret,
},
})
);
};
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
const {
dir,
isPackaged = false,
resourcesPath,
serverSecret,
env: additionalEnv = {},
externalGoosed,
logger = defaultLogger,
} = options;
const errorLog: string[] = [];
const workingDir = dir || os.homedir();
if (externalGoosed?.enabled && externalGoosed.url) {
const url = externalGoosed.url.replace(/\/$/, '');
logger.info(`Using external goosed backend at ${url}`);
return {
baseUrl: url,
workingDir,
process: null,
errorLog,
cleanup: async () => {
logger.info('Not killing external process that is managed externally');
},
client: goosedClientForUrlAndSecret(url, serverSecret),
};
}
if (process.env.GOOSE_EXTERNAL_BACKEND) {
const port = process.env.GOOSE_PORT || '3000';
const url = `http://127.0.0.1:${port}`;
logger.info(`Using external goosed backend from env at ${url}`);
return {
baseUrl: url,
workingDir,
process: null,
errorLog,
cleanup: async () => {
logger.info('Not killing external process that is managed externally');
},
client: goosedClientForUrlAndSecret(url, serverSecret),
};
}
const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });
const port = await findAvailablePort();
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
const baseUrl = `http://127.0.0.1:${port}`;
const spawnEnv = {
...process.env,
...buildGoosedEnv(port, serverSecret, goosedPath),
};
for (const [key, value] of Object.entries(additionalEnv)) {
if (value !== undefined) {
spawnEnv[key] = value;
}
}
const isWindows = process.platform === 'win32';
const spawnOptions = {
env: spawnEnv,
cwd: workingDir,
windowsHide: true,
detached: isWindows,
shell: false as const,
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
};
const safeSpawnOptions = {
...spawnOptions,
env: Object.fromEntries(
Object.entries(spawnOptions.env).map(([k, v]) =>
k.toLowerCase().includes('secret') || k.toLowerCase().includes('key')
? [k, '[REDACTED]']
: [k, v]
)
),
};
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
goosedProcess.stdout?.on('data', (data: Buffer) => {
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
});
goosedProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
errorLog.push(line);
if (isFatalError(line)) {
logger.error(`goosed stderr for port ${port} and dir ${workingDir}: ${line}`);
}
}
}
});
goosedProcess.on('exit', (code) => {
logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
});
goosedProcess.on('error', (err) => {
logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
errorLog.push(err.message);
});
const cleanup = async (): Promise<void> => {
return new Promise<void>((resolve) => {
if (!goosedProcess || goosedProcess.killed) {
resolve();
return;
}
goosedProcess.on('close', () => {
resolve();
});
logger.info('Terminating goosed server');
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', goosedProcess.pid!.toString(), '/f', '/t']);
} else {
goosedProcess.kill('SIGTERM');
}
} catch (error) {
logger.error('Error while terminating goosed process:', error);
}
setTimeout(() => {
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
goosedProcess.kill('SIGKILL');
}
resolve();
}, 5000);
});
};
logger.info(`Goosed server successfully started on port ${port}`);
return {
baseUrl,
workingDir,
process: goosedProcess,
errorLog,
cleanup,
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
};
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { Recipe, scanRecipe } from '../recipe';
import { createUserMessage } from '../types/message';
import { Message } from '../api';
import { substituteParameters } from '../utils/providerUtils';
import { substituteParameters } from '../utils/parameterSubstitution';
import { updateSessionUserRecipeValues } from '../api';
import { useChatContext } from '../contexts/ChatContext';
import { ChatType } from '../types/chat';
+18 -13
View File
@@ -23,7 +23,8 @@ import path from 'node:path';
import os from 'node:os';
import { spawn } from 'child_process';
import 'dotenv/config';
import { checkServerStatus, startGoosed } from './goosed';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
@@ -43,7 +44,7 @@ import {
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import './utils/recipeHash';
import { Client, createClient, createConfig } from './api/client';
import { Client } from './api/client';
import { GooseApp } from './api';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
@@ -483,14 +484,27 @@ const createChat = async (
const serverSecret = getServerSecret(settings);
const goosedResult = await startGoosed({
app,
serverSecret,
dir: dir || os.homedir(),
env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT },
externalGoosed: settings.externalGoosed,
isPackaged: app.isPackaged,
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
logger: log,
});
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
app.on('will-quit', async () => {
log.info('App quitting, terminating goosed server');
await goosedResult.cleanup();
});
const {
baseUrl,
workingDir,
process: goosedProcess,
errorLog,
client: goosedClient,
} = goosedResult;
const mainWindowState = windowStateKeeper({
defaultWidth: 940,
@@ -543,15 +557,6 @@ const createChat = async (
.catch((err) => log.info('failed to install react dev tools:', err));
}
const goosedClient = createClient(
createConfig({
baseUrl,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': serverSecret,
},
})
);
goosedClients.set(mainWindow.id, goosedClient);
const serverReady = await checkServerStatus(goosedClient, errorLog);
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { substituteParameters } from '../providerUtils';
import { substituteParameters } from '../parameterSubstitution';
describe('providerUtils', () => {
describe('parameterSubstitution', () => {
describe('substituteParameters', () => {
it('should substitute simple parameters', () => {
const text = 'Hello {{name}}, welcome to {{app}}!';
@@ -83,12 +83,12 @@ describe('providerUtils', () => {
it('should handle complex substitution scenario', () => {
const text = `
Welcome {{user_name}}!
Your account details:
- ID: {{user_id}}
- Email: {{user_email}}
- App: {{app_name}}
Thank you for using {{app_name}}!
`;
@@ -102,12 +102,12 @@ describe('providerUtils', () => {
const result = substituteParameters(text, params);
const expected = `
Welcome John Doe!
Your account details:
- ID: 12345
- Email: john@example.com
- App: MyApp
Thank you for using MyApp!
`;
@@ -0,0 +1,11 @@
export const substituteParameters = (text: string, params: Record<string, string>): string => {
let substitutedText = text;
for (const key in params) {
// Escape special characters in the key (parameter) and match optional whitespace
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
substitutedText = substitutedText.replace(regex, params[key]);
}
return substitutedText;
};
-77
View File
@@ -1,77 +0,0 @@
import {
initializeBundledExtensions,
syncBundledExtensions,
} from '../components/settings/extensions';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { Recipe, updateAgentProvider, updateFromSession } from '../api';
// Helper function to substitute parameters in text
export const substituteParameters = (text: string, params: Record<string, string>): string => {
let substitutedText = text;
for (const key in params) {
// Escape special characters in the key (parameter) and match optional whitespace
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
substitutedText = substitutedText.replace(regex, params[key]);
}
return substitutedText;
};
export const initializeSystem = async (
sessionId: string,
provider: string,
model: string,
options?: {
getExtensions?: (b: boolean) => Promise<FixedExtensionEntry[]>;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
recipeParameters?: Record<string, string> | null;
recipe?: Recipe;
}
) => {
try {
console.log(
'initializing agent with provider',
provider,
'model',
model,
'sessionId',
sessionId
);
await updateAgentProvider({
body: {
session_id: sessionId,
provider,
model,
},
throwOnError: true,
});
if (!sessionId) {
console.log('This will not end well');
}
await updateFromSession({
body: {
session_id: sessionId,
},
throwOnError: true,
});
if (!options?.getExtensions || !options?.addExtension) {
console.warn('Extension helpers not provided in alpha mode');
return;
}
// Initialize or sync built-in extensions into config.yaml
let refreshedExtensions = await options.getExtensions(false);
if (refreshedExtensions.length === 0) {
await initializeBundledExtensions(options.addExtension);
} else {
await syncBundledExtensions(refreshedExtensions, options.addExtension);
}
} catch (error) {
console.error('Failed to initialize agent:', error);
throw error;
}
};
+308
View File
@@ -0,0 +1,308 @@
/**
* Integration tests for the goosed binary using the TypeScript API client.
*
* These tests spawn a real goosed process and issue requests via the
* auto-generated API client to verify the server is working correctly.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupGoosed, type GoosedTestContext } from './setup';
import {
status,
readConfig,
providers,
startAgent,
stopAgent,
listSessions,
getSession,
updateAgentProvider,
reply,
} from '../../src/api';
import { execSync } from 'child_process';
import os from 'node:os';
const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
function getUserPath(): string[] {
try {
const userShell = process.env.SHELL || '/bin/bash';
const path = execSync(`${userShell} -l -i -c 'echo $PATH'`, {
encoding: 'utf-8',
timeout: 5000,
env: {
PATH: CONSTRAINED_PATH,
},
}).trim();
const delimiter = process.platform === 'win32' ? ';' : ':';
return path.split(delimiter).filter((entry: string) => entry.length > 0);
} catch (error) {
console.error('Error executing shell:', error);
throw error;
}
}
describe('goosed API integration tests', () => {
let ctx: GoosedTestContext;
beforeAll(async () => {
const configYaml = `
extensions:
developer:
enabled: true
type: builtin
name: developer
description: General development tools useful for software engineering.
display_name: Developer
timeout: 300
bundled: true
available_tools: []
`;
ctx = await setupGoosed({ pathOverride: '/usr/bin:/bin', configYaml });
});
afterAll(async () => {
await ctx.cleanup();
});
describe('health', () => {
it('should respond to status endpoint', async () => {
const response = await status({ client: ctx.client });
expect(response.response).toBeOkResponse();
expect(response.data).toBeDefined();
});
});
describe('configuration', () => {
it('should read config value (or return null for missing key)', async () => {
const response = await readConfig({
client: ctx.client,
body: {
key: 'GOOSE_PROVIDER',
is_secret: false,
},
});
expect(response.response).toBeOkResponse();
});
});
describe('providers', () => {
it('should list available providers', async () => {
const response = await providers({ client: ctx.client });
expect(response.response).toBeOkResponse();
expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
});
});
describe('sessions', () => {
it('should start an agent and create a session', async () => {
const startResponse = await startAgent({
client: ctx.client,
body: {
working_dir: os.tmpdir(),
},
});
expect(startResponse.response).toBeOkResponse();
expect(startResponse.data).toBeDefined();
const session = startResponse.data!;
expect(session.id).toBeDefined();
expect(session.name).toBeDefined();
const getResponse = await getSession({
client: ctx.client,
path: {
session_id: session.id,
},
});
expect(getResponse.response).toBeOkResponse();
expect(getResponse.data).toBeDefined();
expect(getResponse.data!.id).toBe(session.id);
});
it('should list sessions', async () => {
const sessionsResponse = await listSessions({ client: ctx.client });
expect(sessionsResponse.response).toBeOkResponse();
expect(sessionsResponse.data).toBeDefined();
expect(sessionsResponse.data!.sessions).toBeDefined();
expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true);
});
});
describe('messaging', () => {
it('should accept a message request to /reply endpoint', async () => {
// Start a session first
const startResponse = await startAgent({
client: ctx.client,
body: {
working_dir: os.tmpdir(),
},
});
expect(startResponse.response).toBeOkResponse();
const sessionId = startResponse.data!.id;
const abortController = new AbortController();
const { stream } = await reply({
client: ctx.client,
body: {
session_id: sessionId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'text',
text: 'Hello',
},
],
metadata: {
userVisible: true,
agentVisible: true,
},
},
},
throwOnError: true,
signal: abortController.signal,
});
const timeout = setTimeout(() => abortController.abort(), 1000);
try {
for await (const event of stream) {
expect(event).toBeDefined();
break;
}
} catch {
// Aborted or error, that's fine
}
clearTimeout(timeout);
await stopAgent({
client: ctx.client,
body: {
session_id: sessionId,
},
});
});
});
describe('the developer tool', () => {
it('should see the full PATH when calling the developer tool', async (testContext) => {
const currentPath = getUserPath();
const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry));
if (!pathEntry) {
expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`);
}
let configResponse = await readConfig({
client: ctx.client,
body: {
key: 'GOOSE_PROVIDER',
is_secret: false,
},
});
let providerName = configResponse.data as string | null | undefined;
if (!providerName) {
testContext.skip('Skipping tool execution test - no GOOSE_PROVIDER configured');
return;
}
const modelResponse = await readConfig({
client: ctx.client,
body: {
key: 'GOOSE_MODEL',
is_secret: false,
},
});
const modelName = (modelResponse.data as string | null) || undefined;
const startResponse = await startAgent({
client: ctx.client,
body: {
working_dir: os.tmpdir(),
},
});
expect(startResponse.response).toBeOkResponse();
const sessionId = startResponse.data!.id;
const providerResponse = await updateAgentProvider({
client: ctx.client,
body: {
session_id: sessionId,
provider: providerName,
model: modelName,
},
});
expect(providerResponse.response).toBeOkResponse();
const abortController = new AbortController();
const { stream } = await reply({
client: ctx.client,
body: {
session_id: sessionId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'text',
text: 'Use your developer shell tool to read $PATH and return its content directly, with no further information about it',
},
],
metadata: {
userVisible: true,
agentVisible: true,
},
},
},
throwOnError: true,
signal: abortController.signal,
});
let returnedPath: string | undefined = undefined;
const timeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout
try {
for await (const event of stream) {
console.log('stream: ', JSON.stringify(event));
if (event.type === 'Message') {
const content = event.message?.content?.[0];
if (content?.type === 'toolResponse') {
const toolResult = content as {
toolResult?: { value?: { content?: Array<{ text?: string }> } };
};
const output = toolResult?.toolResult?.value?.content?.[0]?.text;
if (output && output.includes('/usr')) {
clearTimeout(timeout);
abortController.abort();
returnedPath = output;
break;
}
}
}
}
} catch (error) {
// Aborted or error
if (!(error instanceof Error && error.name === 'AbortError')) {
console.log('Stream error: ', error);
}
}
clearTimeout(timeout);
await stopAgent({
client: ctx.client,
body: {
session_id: sessionId,
},
});
expect(returnedPath, 'the agent should return a value for $PATH').toBeDefined();
expect(returnedPath, '$PATH should contain the expected entry').toContain(pathEntry);
});
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Integration test setup for testing the goosed binary via the TypeScript API client.
*
* This test suite spawns a real goosed process and issues requests via the
* auto-generated API client.
*/
import type { ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Client } from '../../src/api/client';
import { startGoosed as startGoosedBase, checkServerStatus, type Logger } from '../../src/goosed';
import { expect } from 'vitest';
function stringifyResponse(response: Response) {
const details = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
headers: response.headers ? Object.fromEntries(response.headers) : undefined,
};
return JSON.stringify(details, null, 2);
}
expect.extend({
toBeOkResponse(response) {
const pass = response.ok === true;
return {
pass,
message: () =>
pass
? 'expected response not to be ok'
: `expected response to be ok, got: ${stringifyResponse(response)}`,
};
},
});
const TEST_SECRET_KEY = 'test';
export interface GoosedTestContext {
client: Client;
baseUrl: string;
secretKey: string;
process: ChildProcess | null;
cleanup: () => Promise<void>;
}
export async function setupGoosed({
pathOverride,
configYaml,
}: {
pathOverride?: string;
configYaml?: string;
}): Promise<GoosedTestContext> {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'goose-app-root-'));
if (configYaml) {
await fs.promises.mkdir(path.join(tempDir, 'config'), { recursive: true });
await fs.promises.writeFile(path.join(tempDir, 'config', 'config.yaml'), configYaml);
}
const testLogger: Logger = {
info: (...args) => {
if (process.env.DEBUG) {
console.log('[goosed]', ...args);
}
},
error: (...args) => console.error('[goosed]', ...args),
};
const additionalEnv: Record<string, string> = {
GOOSE_PATH_ROOT: tempDir,
};
if (pathOverride) {
additionalEnv.PATH = pathOverride;
}
const {
baseUrl,
process: goosedProcess,
client,
cleanup: baseCleanup,
errorLog,
} = await startGoosedBase({
serverSecret: TEST_SECRET_KEY,
env: additionalEnv,
logger: testLogger,
});
if (!goosedProcess) {
throw new Error('Expected goosed process to be started, but got external backend');
}
const cleanup = async (): Promise<void> => {
// dump server logs to test logs, visible if there are test failures
try {
const logsPath = path.join(tempDir, 'state', 'logs', 'server');
if (fs.existsSync(logsPath)) {
const logDirs = await fs.promises.readdir(logsPath);
for (const logDir of logDirs) {
const logFiles = await fs.promises.readdir(path.join(logsPath, logDir));
for (const logFile of logFiles) {
const logPath = path.join(logsPath, logDir, logFile);
const logContent = await fs.promises.readFile(logPath, 'utf8');
console.log(logContent);
}
}
}
} catch {
// Logs may not exist
}
await baseCleanup();
await fs.promises.rm(tempDir, { recursive: true, force: true });
};
const serverReady = await checkServerStatus(client, errorLog);
if (!serverReady) {
await cleanup();
console.error('Server stderr:', errorLog.join('\n'));
throw new Error('Failed to start goosed');
}
return {
client,
baseUrl,
secretKey: TEST_SECRET_KEY,
process: goosedProcess,
cleanup,
};
}
+10
View File
@@ -0,0 +1,10 @@
import 'vitest';
declare module 'vitest' {
interface Assertion<T = unknown> {
toBeOkResponse(): T;
}
interface AsymmetricMatchersContaining {
toBeOkResponse(): unknown;
}
}
+3 -3
View File
@@ -38,8 +38,8 @@
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noImplicitReturns": true
"noImplicitReturns": true,
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["src", "tests/integration"],
"references": [{ "path": "./tsconfig.node.json" }],
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'node',
include: ['tests/integration/**/*.test.ts'],
testTimeout: 60000,
hookTimeout: 60000,
pool: 'forks',
singleFork: true,
silent: 'passed-only',
},
});