mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+6
-6
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
import 'vitest';
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T = unknown> {
|
||||
toBeOkResponse(): T;
|
||||
}
|
||||
interface AsymmetricMatchersContaining {
|
||||
toBeOkResponse(): unknown;
|
||||
}
|
||||
}
|
||||
@@ -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" }],
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user