Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions tests/cross_language/a2a/go_ts/go_client/go_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
*/

import type {Event} from '@google/adk';
import {execSync, spawn} from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {spawn} from 'node:child_process';
import * as readline from 'node:readline';
import {ensureGoModules} from '../../../go_modules.js';

export interface GoAgentParams {
dir: string;
Expand All @@ -28,17 +27,7 @@ export class GoAgent {
}

public async *run(userMessage: string): AsyncGenerator<Event, void, unknown> {
if (!fs.existsSync(path.join(this.dir, 'go.sum'))) {
try {
execSync('go mod tidy', {
cwd: this.dir,
stdio: 'inherit',
env: process.env,
});
} catch (_e: unknown) {
console.warn('Failed to run go mod tidy');
}
}
await ensureGoModules(this.dir);

const child = spawn(
'go',
Expand Down
26 changes: 8 additions & 18 deletions tests/cross_language/a2a/ts_go/go_backend/go_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {execSync, spawn} from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {spawn} from 'node:child_process';
import {BaseTestServer} from '../../../../integration/test_case_utils.js';
import {ensureGoModules} from '../../../go_modules.js';

/**
* Interface representing the parameters for creating the test Go agent server.
Expand All @@ -18,7 +17,11 @@ export interface TestGoServerParams {
startFailureTimeout?: number;
}

const DEFAULT_TIMEOUT = 30000;
/**
* Readiness budget (ms). Matches `AdkTsApiServer`'s 60000 because `go run .`
* compiles the module before the server boots.
*/
const DEFAULT_TIMEOUT = 60000;

/**
* Go server for testing.
Expand All @@ -32,20 +35,7 @@ export class AdkGoServer extends BaseTestServer {
}

async start(): Promise<void> {
if (!fs.existsSync(path.join(this.params.serverDir, 'go.sum'))) {
try {
console.log('Running go mod tidy to fetch dependencies...');
execSync('go mod tidy', {
cwd: this.params.serverDir,
stdio: 'inherit',
env: process.env,
});
} catch (_e: unknown) {
console.warn(
'Failed to run go mod tidy, ensure go is installed and network is available.',
);
}
}
await ensureGoModules(this.params.serverDir);

await this.startProcess({
spawnProcess: () => {
Expand Down
74 changes: 74 additions & 0 deletions tests/cross_language/go_modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {execFile, type ExecFileException} from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {promisify} from 'node:util';

const execFileAsync = promisify(execFile);

/**
* Budget (ms) for the one-off `go mod tidy` that populates a module's `go.sum`.
* A cold tidy measured 3.2s; 45000 stays under the 60000 per-hook and per-test
* budget of the cross-language suites, so this helper's message wins over a
* generic Vitest timeout.
*/
export const GO_MOD_TIDY_TIMEOUT_MS = 45000;

function isExecFileFailure(
error: unknown,
): error is Pick<ExecFileException, 'code' | 'killed' | 'stderr'> {
return typeof error === 'object' && error !== null;
}

function describeFailure(error: unknown): string {
if (!isExecFileFailure(error)) {
return String(error);
}
if (error.code === 'ENOENT') {
return "the 'go' executable was not found on PATH";
}
if (error.killed) {
return `it exceeded its ${GO_MOD_TIDY_TIMEOUT_MS}ms budget`;
}
const stderr = error.stderr?.trim();
return stderr
? `it exited with code ${error.code}\n${stderr}`
: `it exited with code ${error.code}`;
}

/**
* Populates `moduleDir`'s `go.sum` if it is absent. `go.sum` is gitignored, so
* a fresh clone never has one.
*
* This is a no-op when `go.sum` already exists, which is always the case on CI:
* the cross-language workflow tidies both modules in a dedicated step.
*
* @param moduleDir Absolute path of a directory that contains a `go.mod`.
* @throws Error naming the module directory and the concrete cause: a missing
* `go` executable, a non-zero exit, or the timeout. Nothing is swallowed,
* so the caller never spawns `go run .` against an unresolved module.
*/
export async function ensureGoModules(moduleDir: string): Promise<void> {
if (fs.existsSync(path.join(moduleDir, 'go.sum'))) {
return;
}

try {
await execFileAsync('go', ['mod', 'tidy'], {
cwd: moduleDir,
timeout: GO_MOD_TIDY_TIMEOUT_MS,
});
} catch (error: unknown) {
throw new Error(
`go mod tidy failed in ${moduleDir}: ${describeFailure(error)}. ` +
`Install the Go toolchain (https://go.dev/dl/) or run 'go mod tidy' ` +
`in that directory manually before running the cross-language tests.`,
{cause: error},
);
}
}
150 changes: 150 additions & 0 deletions tests/cross_language/go_modules_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type {ExecFileException, ExecFileOptions} from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {ensureGoModules, GO_MOD_TIDY_TIMEOUT_MS} from './go_modules.js';

type ExecFileCallback = (
error: ExecFileException | null,
stdout: string,
stderr: string,
) => void;

/**
* `promisify` only uses a function's `util.promisify.custom` implementation,
* which this replacement does not carry, so it falls back to the generic
* callback path. The mock must therefore call its last argument.
*/
const execFileMock = vi.hoisted(() =>
vi.fn<
(
file: string,
args: readonly string[],
options: ExecFileOptions,
callback: ExecFileCallback,
) => void
>(),
);

vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof import('node:child_process')>()),
execFile: execFileMock,
}));

/** Fails the tidy with `error`, as `execFile`'s callback would. */
function rejectWith(error: ExecFileException): void {
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(error, '', '');
});
}

describe('ensureGoModules', () => {
let moduleDir: string;

beforeEach(() => {
moduleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adk-go-modules-'));
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(null, '', '');
});
});

afterEach(() => {
fs.rmSync(moduleDir, {recursive: true, force: true});
execFileMock.mockReset();
});

it('does not tidy when go.sum already exists', async () => {
fs.writeFileSync(path.join(moduleDir, 'go.sum'), '');

await ensureGoModules(moduleDir);

expect(execFileMock).not.toHaveBeenCalled();
});

it('tidies the module directory when go.sum is absent', async () => {
await ensureGoModules(moduleDir);

expect(execFileMock).toHaveBeenCalledWith(
'go',
['mod', 'tidy'],
expect.objectContaining({
cwd: moduleDir,
timeout: GO_MOD_TIDY_TIMEOUT_MS,
}),
expect.any(Function),
);
});

it('rejects by name when the go executable is missing', async () => {
rejectWith(Object.assign(new Error('spawn go ENOENT'), {code: 'ENOENT'}));

await expect(ensureGoModules(moduleDir)).rejects.toThrow(
`go mod tidy failed in ${moduleDir}: the 'go' executable was not found ` +
`on PATH. Install the Go toolchain (https://go.dev/dl/) or run ` +
`'go mod tidy' in that directory manually before running the ` +
`cross-language tests.`,
);
});

it('names the budget when the tidy times out', async () => {
rejectWith(Object.assign(new Error('killed'), {killed: true, code: null}));

await expect(ensureGoModules(moduleDir)).rejects.toThrow(
`it exceeded its ${GO_MOD_TIDY_TIMEOUT_MS}ms budget`,
);
});

it('reports stderr when the tidy exits non-zero', async () => {
rejectWith(
Object.assign(new Error('exit 1'), {
code: 1,
stderr: 'go: some/module@v1: invalid version\n',
}),
);

await expect(ensureGoModules(moduleDir)).rejects.toThrow(
'it exited with code 1\ngo: some/module@v1: invalid version.',
);
});

it('omits the stderr line when the failed tidy wrote nothing', async () => {
rejectWith(Object.assign(new Error('exit 2'), {code: 2, stderr: ''}));

await expect(ensureGoModules(moduleDir)).rejects.toThrow(
'it exited with code 2. Install the Go toolchain',
);
});

it('stringifies a rejection that is not an object', async () => {
execFileMock.mockImplementation(() => {
throw 'go exploded';
});

await expect(ensureGoModules(moduleDir)).rejects.toThrow(
`go mod tidy failed in ${moduleDir}: go exploded.`,
);
});

it('keeps the original failure as the error cause', async () => {
const original = Object.assign(new Error('spawn go ENOENT'), {
code: 'ENOENT',
});
rejectWith(original);

const rejection = await ensureGoModules(moduleDir).catch(
(error: unknown) => error,
);

if (!(rejection instanceof Error)) {
expect.fail(`expected a rejection, got ${String(rejection)}`);
}
expect(rejection.cause).toBe(original);
});
});
Loading