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
+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;
}
}